-
Notifications
You must be signed in to change notification settings - Fork 0
Add preventUniversalLinks prop to sandbox hosts from UL handoff #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -54,7 +54,6 @@ android/gradle | |
| android/gradlew | ||
| android/gradlew.bat | ||
|
|
||
| lib/ | ||
| .classpath | ||
| .project | ||
| .settings/ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 ?: @""; | ||
| 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; | ||
| } | ||
| } | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 | ||
|
|
||
| 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; |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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; |
There was a problem hiding this comment.
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
preventUniversalLinksmatches every nil-host URL.request.URL.host.lowercaseString ?: @""plus[host isEqualToString:lowered]means an accidental""from JS (typo, bad config, env var) would matchabout:blank,data:, andfile://URLs and trigger cancel+reissue on all of them.A single guard inside the loop defends against it: