Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ android/gradle
android/gradlew
android/gradlew.bat

lib/
.classpath
.project
.settings/
Expand Down
7 changes: 7 additions & 0 deletions apple/RNCWebView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,13 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &
#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000 /* iOS 14 */
REMAP_WEBVIEW_PROP(limitsNavigationsToAppBoundDomains)
#endif
if(oldViewProps.preventUniversalLinks != newViewProps.preventUniversalLinks) {
NSMutableArray *preventUniversalLinks = [NSMutableArray array];
for (const auto &host: newViewProps.preventUniversalLinks) {
[preventUniversalLinks addObject: RCTNSStringFromString(host)];
}
[_view setPreventUniversalLinks:preventUniversalLinks];
}
#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 140500 /* iOS 14.5 */
REMAP_WEBVIEW_PROP(textInteractionEnabled)
#endif
Expand Down
2 changes: 2 additions & 0 deletions apple/RNCWebViewImpl.h
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ shouldStartLoadForRequest:(NSMutableDictionary<NSString *, id> *)request
@property (nonatomic, assign) BOOL limitsNavigationsToAppBoundDomains;
#endif

@property (nonatomic, copy) NSArray<NSString *> * _Nullable preventUniversalLinks;

#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 140500 /* iOS 14.5 */
@property (nonatomic, assign) BOOL textInteractionEnabled;
#endif
Expand Down
45 changes: 45 additions & 0 deletions apple/RNCWebViewImpl.m
Original file line number Diff line number Diff line change
Expand Up @@ -1381,6 +1381,51 @@ - (void) webView:(WKWebView *)webView
BOOL isTopFrame = [request.URL isEqual:request.mainDocumentURL];
BOOL hasTargetFrame = navigationAction.targetFrame != nil;

// Universal Link handoff bypass.
// When the request host matches an entry in `preventUniversalLinks`
// (domain-suffix match with dot boundary), the navigation is canceled
// and re-issued via `[webView loadRequest:]` — the one nav type iOS
// does not consider for Universal Link handoff. Without this, a
// navigation to a UL-eligible URL (e.g. an OAuth redirect targeting a
// host whose AASA registers Universal Links for this app) would hand
// the user off to the installed third-party app and break the
// embedded flow.
//
// Scoped to specified hosts on purpose: reissuing every top-frame nav
// would lose POST bodies and consume single-use auth codes when the
// chain runs through other hosts (e.g. an identity provider during
// OAuth).
//
// An associated-object flag breaks the cancel/reissue loop: the
// reissued nav re-enters this method with the flag set, we clear it
// and fall through to the normal handler.
if (_preventUniversalLinks.count > 0 && isTopFrame && hasTargetFrame) {
static const void *kRNCUlBypassKey = &kRNCUlBypassKey;
NSString *host = request.URL.host.lowercaseString ?: @"";
BOOL hostMatches = NO;
for (NSString *pattern in _preventUniversalLinks) {
NSString *lowered = pattern.lowercaseString ?: @"";
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] Empty-string entry in preventUniversalLinks matches every nil-host URL.

request.URL.host.lowercaseString ?: @"" plus [host isEqualToString:lowered] means an accidental "" from JS (typo, bad config, env var) would match about:blank, data:, and file:// URLs and trigger cancel+reissue on all of them.

A single guard inside the loop defends against it:

for (NSString *pattern in _preventUniversalLinks) {
  NSString *lowered = pattern.lowercaseString;
  if (lowered.length == 0) continue;
  if ([host isEqualToString:lowered] ||
      [host hasSuffix:[@"." stringByAppendingString:lowered]]) {
    hostMatches = YES;
    break;
  }
}

if ([host isEqualToString:lowered] ||
[host hasSuffix:[@"." stringByAppendingString:lowered]]) {
hostMatches = YES;
break;
}
}
if (hostMatches) {
NSNumber *bypassFlag = objc_getAssociatedObject(webView, kRNCUlBypassKey);
if ([bypassFlag boolValue]) {
objc_setAssociatedObject(webView, kRNCUlBypassKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
} else {
objc_setAssociatedObject(webView, kRNCUlBypassKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
decisionHandler(WKNavigationActionPolicyCancel);
dispatch_async(dispatch_get_main_queue(), ^{
[webView loadRequest:request];
});
return;
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Non-GET requests to a UL host silently lose their body on reissue.

The PR description correctly identifies that [webView loadRequest:] strips POST bodies — that's the explicit reason for scoping to a host list rather than reissuing every top-frame nav. But within a host that's in preventUniversalLinks, a POST navigation (e.g. an in-page form submit on goodreads.com) still hits this branch and gets reissued without its body.

UL handoff only fires on GET-style top-frame navs anyway, so it's safe to skip reissue for non-GET methods. Suggest:

if (hostMatches) {
  NSString *method = request.HTTPMethod ?: @"GET";
  if (![method isEqualToString:@"GET"]) {
    // Reissuing strips the body. UL handoff doesn't fire on non-GET navs,
    // so fall through to the normal handler.
  } else {
    NSNumber *bypassFlag = objc_getAssociatedObject(webView, kRNCUlBypassKey);
    // ... existing reissue logic
  }
}

}

if (_onOpenWindow && !hasTargetFrame) {
// When OnOpenWindow should be called, we want to prevent the navigation
// If not prevented, the `decisionHandler` is called first and after that `createWebViewWithConfiguration` is called
Expand Down
2 changes: 2 additions & 0 deletions apple/RNCWebViewManager.mm
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ - (RNCView *)view
RCT_EXPORT_VIEW_PROPERTY(limitsNavigationsToAppBoundDomains, BOOL)
#endif

RCT_EXPORT_VIEW_PROPERTY(preventUniversalLinks, NSArray)

#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 140500 /* iOS 14.5 */
RCT_EXPORT_VIEW_PROPERTY(textInteractionEnabled, BOOL)
#endif
Expand Down
8 changes: 8 additions & 0 deletions lib/NativeRNCWebViewModule.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { TurboModule } from 'react-native';
import { Double } from 'react-native/Libraries/Types/CodegenTypes';
export interface Spec extends TurboModule {
isFileUploadSupported(): Promise<boolean>;
shouldStartLoadWithLockIdentifier(shouldStart: boolean, lockIdentifier: Double): void;
}
declare const _default: Spec;
export default _default;
1 change: 1 addition & 0 deletions lib/NativeRNCWebViewModule.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

249 changes: 249 additions & 0 deletions lib/RNCWebViewNativeComponent.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import type { HostComponent, ViewProps } from 'react-native';
import { DirectEventHandler, Double, Int32, WithDefault } from 'react-native/Libraries/Types/CodegenTypes';
export type WebViewNativeEvent = Readonly<{
url: string;
loading: boolean;
title: string;
canGoBack: boolean;
canGoForward: boolean;
lockIdentifier: Double;
}>;
export type WebViewCustomMenuSelectionEvent = Readonly<{
label: string;
key: string;
selectedText: string;
}>;
export type WebViewMessageEvent = Readonly<{
url: string;
loading: boolean;
title: string;
canGoBack: boolean;
canGoForward: boolean;
lockIdentifier: Double;
data: string;
}>;
export type WebViewOpenWindowEvent = Readonly<{
targetUrl: string;
}>;
export type WebViewHttpErrorEvent = Readonly<{
url: string;
loading: boolean;
title: string;
canGoBack: boolean;
canGoForward: boolean;
lockIdentifier: Double;
description: string;
statusCode: Int32;
}>;
export type WebViewErrorEvent = Readonly<{
url: string;
loading: boolean;
title: string;
canGoBack: boolean;
canGoForward: boolean;
lockIdentifier: Double;
domain?: string;
code: Int32;
description: string;
}>;
export type WebViewNativeProgressEvent = Readonly<{
url: string;
loading: boolean;
title: string;
canGoBack: boolean;
canGoForward: boolean;
lockIdentifier: Double;
progress: Double;
}>;
export type WebViewNavigationEvent = Readonly<{
url: string;
loading: boolean;
title: string;
canGoBack: boolean;
canGoForward: boolean;
lockIdentifier: Double;
navigationType: 'click' | 'formsubmit' | 'backforward' | 'reload' | 'formresubmit' | 'other';
mainDocumentURL?: string;
}>;
export type ShouldStartLoadRequestEvent = Readonly<{
url: string;
loading: boolean;
title: string;
canGoBack: boolean;
canGoForward: boolean;
lockIdentifier: Double;
navigationType: 'click' | 'formsubmit' | 'backforward' | 'reload' | 'formresubmit' | 'other';
mainDocumentURL?: string;
isTopFrame: boolean;
}>;
type ScrollEvent = Readonly<{
contentInset: {
bottom: Double;
left: Double;
right: Double;
top: Double;
};
contentOffset: {
y: Double;
x: Double;
};
contentSize: {
height: Double;
width: Double;
};
layoutMeasurement: {
height: Double;
width: Double;
};
targetContentOffset?: {
y: Double;
x: Double;
};
velocity?: {
y: Double;
x: Double;
};
zoomScale?: Double;
responderIgnoreScroll?: boolean;
}>;
type WebViewRenderProcessGoneEvent = Readonly<{
didCrash: boolean;
}>;
type WebViewDownloadEvent = Readonly<{
downloadUrl: string;
}>;
export interface NativeProps extends ViewProps {
allowFileAccess?: boolean;
allowsProtectedMedia?: boolean;
allowsFullscreenVideo?: boolean;
androidLayerType?: WithDefault<'none' | 'software' | 'hardware', 'none'>;
cacheMode?: WithDefault<'LOAD_DEFAULT' | 'LOAD_CACHE_ELSE_NETWORK' | 'LOAD_NO_CACHE' | 'LOAD_CACHE_ONLY', 'LOAD_DEFAULT'>;
domStorageEnabled?: boolean;
downloadingMessage?: string;
forceDarkOn?: boolean;
geolocationEnabled?: boolean;
lackPermissionToDownloadMessage?: string;
messagingModuleName: string;
minimumFontSize?: Int32;
mixedContentMode?: WithDefault<'never' | 'always' | 'compatibility', 'never'>;
nestedScrollEnabled?: boolean;
onContentSizeChange?: DirectEventHandler<WebViewNativeEvent>;
onRenderProcessGone?: DirectEventHandler<WebViewRenderProcessGoneEvent>;
overScrollMode?: string;
saveFormDataDisabled?: boolean;
scalesPageToFit?: WithDefault<boolean, true>;
setBuiltInZoomControls?: WithDefault<boolean, true>;
setDisplayZoomControls?: boolean;
setSupportMultipleWindows?: WithDefault<boolean, true>;
textZoom?: Int32;
thirdPartyCookiesEnabled?: WithDefault<boolean, true>;
hasOnScroll?: boolean;
allowingReadAccessToURL?: string;
allowsBackForwardNavigationGestures?: boolean;
allowsInlineMediaPlayback?: boolean;
allowsPictureInPictureMediaPlayback?: boolean;
allowsAirPlayForMediaPlayback?: boolean;
allowsLinkPreview?: WithDefault<boolean, true>;
automaticallyAdjustContentInsets?: WithDefault<boolean, true>;
autoManageStatusBarEnabled?: WithDefault<boolean, true>;
bounces?: WithDefault<boolean, true>;
contentInset?: Readonly<{
top?: Double;
left?: Double;
bottom?: Double;
right?: Double;
}>;
contentInsetAdjustmentBehavior?: WithDefault<'never' | 'automatic' | 'scrollableAxes' | 'always', 'never'>;
contentMode?: WithDefault<'recommended' | 'mobile' | 'desktop', 'recommended'>;
dataDetectorTypes?: WithDefault<ReadonlyArray<'address' | 'link' | 'calendarEvent' | 'trackingNumber' | 'flightNumber' | 'lookupSuggestion' | 'phoneNumber' | 'all' | 'none'>, 'phoneNumber'>;
decelerationRate?: Double;
directionalLockEnabled?: WithDefault<boolean, true>;
enableApplePay?: boolean;
hideKeyboardAccessoryView?: boolean;
keyboardDisplayRequiresUserAction?: WithDefault<boolean, true>;
limitsNavigationsToAppBoundDomains?: boolean;
preventUniversalLinks?: ReadonlyArray<string>;
mediaCapturePermissionGrantType?: WithDefault<'prompt' | 'grant' | 'deny' | 'grantIfSameHostElsePrompt' | 'grantIfSameHostElseDeny', 'prompt'>;
pagingEnabled?: boolean;
pullToRefreshEnabled?: boolean;
refreshControlLightMode?: boolean;
scrollEnabled?: WithDefault<boolean, true>;
scrollsToTop?: WithDefault<boolean, true>;
sharedCookiesEnabled?: boolean;
dragInteractionEnabled?: WithDefault<boolean, true>;
textInteractionEnabled?: WithDefault<boolean, true>;
useSharedProcessPool?: WithDefault<boolean, true>;
onContentProcessDidTerminate?: DirectEventHandler<WebViewNativeEvent>;
onCustomMenuSelection?: DirectEventHandler<WebViewCustomMenuSelectionEvent>;
onFileDownload?: DirectEventHandler<WebViewDownloadEvent>;
menuItems?: ReadonlyArray<Readonly<{
label: string;
key: string;
}>>;
suppressMenuItems?: Readonly<string>[];
hasOnFileDownload?: boolean;
fraudulentWebsiteWarningEnabled?: WithDefault<boolean, true>;
allowFileAccessFromFileURLs?: boolean;
allowUniversalAccessFromFileURLs?: boolean;
applicationNameForUserAgent?: string;
basicAuthCredential?: Readonly<{
username: string;
password: string;
}>;
cacheEnabled?: WithDefault<boolean, true>;
incognito?: boolean;
injectedJavaScript?: string;
injectedJavaScriptBeforeContentLoaded?: string;
injectedJavaScriptForMainFrameOnly?: WithDefault<boolean, true>;
injectedJavaScriptBeforeContentLoadedForMainFrameOnly?: WithDefault<boolean, true>;
javaScriptCanOpenWindowsAutomatically?: boolean;
javaScriptEnabled?: WithDefault<boolean, true>;
webviewDebuggingEnabled?: boolean;
mediaPlaybackRequiresUserAction?: WithDefault<boolean, true>;
messagingEnabled: boolean;
onLoadingError: DirectEventHandler<WebViewErrorEvent>;
onLoadingSubResourceError: DirectEventHandler<WebViewErrorEvent>;
onLoadingFinish: DirectEventHandler<WebViewNavigationEvent>;
onLoadingProgress: DirectEventHandler<WebViewNativeProgressEvent>;
onLoadingStart: DirectEventHandler<WebViewNavigationEvent>;
onHttpError: DirectEventHandler<WebViewHttpErrorEvent>;
onMessage: DirectEventHandler<WebViewMessageEvent>;
onOpenWindow?: DirectEventHandler<WebViewOpenWindowEvent>;
hasOnOpenWindowEvent?: boolean;
onScroll?: DirectEventHandler<ScrollEvent>;
onShouldStartLoadWithRequest: DirectEventHandler<ShouldStartLoadRequestEvent>;
showsHorizontalScrollIndicator?: WithDefault<boolean, true>;
showsVerticalScrollIndicator?: WithDefault<boolean, true>;
indicatorStyle?: WithDefault<'default' | 'black' | 'white', 'default'>;
newSource: Readonly<{
uri?: string;
method?: string;
body?: string;
headers?: ReadonlyArray<Readonly<{
name: string;
value: string;
}>>;
html?: string;
baseUrl?: string;
}>;
userAgent?: string;
injectedJavaScriptObject?: string;
paymentRequestEnabled?: boolean;
}
export interface NativeCommands {
goBack: (viewRef: React.ElementRef<HostComponent<NativeProps>>) => void;
goForward: (viewRef: React.ElementRef<HostComponent<NativeProps>>) => void;
reload: (viewRef: React.ElementRef<HostComponent<NativeProps>>) => void;
stopLoading: (viewRef: React.ElementRef<HostComponent<NativeProps>>) => void;
injectJavaScript: (viewRef: React.ElementRef<HostComponent<NativeProps>>, javascript: string) => void;
requestFocus: (viewRef: React.ElementRef<HostComponent<NativeProps>>) => void;
postMessage: (viewRef: React.ElementRef<HostComponent<NativeProps>>, data: string) => void;
loadUrl: (viewRef: React.ElementRef<HostComponent<NativeProps>>, url: string) => void;
clearFormData: (viewRef: React.ElementRef<HostComponent<NativeProps>>) => void;
clearCache: (viewRef: React.ElementRef<HostComponent<NativeProps>>, includeDiskFiles: boolean) => void;
clearHistory: (viewRef: React.ElementRef<HostComponent<NativeProps>>) => void;
setTintColor: (viewRef: React.ElementRef<HostComponent<NativeProps>>, red: Double, green: Double, blue: Double, alpha: Double) => void;
}
export declare const Commands: NativeCommands;
declare const _default: HostComponent<NativeProps>;
export default _default;
Loading
Loading