Add preventUniversalLinks prop to sandbox hosts from UL handoff#9
Add preventUniversalLinks prop to sandbox hosts from UL handoff#9artemlitch wants to merge 2 commits intoreadwise/v13.16.0from
Conversation
When iOS triggers a Universal Link handoff mid-flow inside a WebView, the user is yanked into the linked third-party app and the embedded flow breaks. This shows up most commonly in embedded auth: e.g. a Goodreads sign-in WebView whose post-Amazon-SSO redirect lands on a UL-eligible URL like /ap-handler/sign-in?IDP=lwa, sending the user into the installed Goodreads app. iOS does not expose a way to predict UL handoff at the navigation delegate — UL is decided in WebKit's process before decidePolicyForNavigationAction fires. The only nav type iOS reliably does NOT consider for UL handoff is host-app-initiated [webView loadRequest:]. This adds a preventUniversalLinks prop (NSArray<NSString *>) for iOS: when set, top-frame navigations whose host matches an entry are canceled and re-issued via [webView loadRequest:]. Match is domain-suffix with a dot boundary, so passing 'goodreads.com' covers 'goodreads.com', 'www.goodreads.com', and any subdomain — but not 'evilgoodreads.com'. The prop takes a host list rather than a boolean because reissuing every top-frame nav unconditionally breaks anything that's not a plain GET — POST form bodies are stripped by NSURLRequest in the navigation action, and reissuing single-use OAuth codes (Amazon callbacks etc.) double-consumes them. Only hosts that actually have AASA registered for this app's bundle ID are at risk of UL handoff, so the app declares them. An associated-object flag on the WKWebView breaks the cancel/reissue loop: the reissued nav re-enters decidePolicyForNavigationAction with the flag set, the impl clears it and falls through to the normal handler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
artemlitch
left a comment
There was a problem hiding this comment.
Code Review
Tight, focused change with a thoroughly-explained design. The associated-object loop guard, dispatch_async after decisionHandler(Cancel), dot-boundary suffix matching, and Fabric+legacy prop wiring all check out. <objc/runtime.h> is already imported via RNCWebViewImpl.m:19, so no missing import.
Two findings — both about edge cases on the host-match branch:
Summary
- 1 MEDIUM — non-GET requests to a UL host silently lose their body on reissue
- 1 LOW — empty-string entry in
preventUniversalLinkswould match nil-host URLs
Neither is blocking given the documented scope (UL-eligible OAuth redirect chains are GETs), but both are cheap to defend against.
| }); | ||
| return; | ||
| } | ||
| } |
There was a problem hiding this comment.
[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
}
}| NSString *host = request.URL.host.lowercaseString ?: @""; | ||
| BOOL hostMatches = NO; | ||
| for (NSString *pattern in _preventUniversalLinks) { | ||
| NSString *lowered = pattern.lowercaseString ?: @""; |
There was a problem hiding this comment.
[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;
}
}| * pass through normally. | ||
| * | ||
| * @platform ios | ||
| */ |
There was a problem hiding this comment.
Minor: the public type here is string[] (mutable), but RNCWebViewNativeComponent.ts declares it as ReadonlyArray<string>. Not a bug — codegen accepts both — but worth aligning to readonly string[] here for symmetry with the native spec and to discourage callers from mutating in place.
Consumer CI blocks 'yarn install' (rekindled rule), and pnpm runs the 'prepare' script when fetching git-hosted packages. Pre-build lib/ and remove prepare so consumers don't need to run it on install. Matches the pattern used in commit 952828e.
Summary
When iOS triggers a Universal Link handoff mid-flow inside an embedded WebView, the user is yanked into the linked third-party app and the embedded flow breaks. This is what's currently happening in Bookwise's Goodreads import: the post-Amazon-SSO redirect chain ends at
https://www.goodreads.com/ap-handler/sign-in?IDP=lwa, which is UL-eligible per Goodreads' AASA, and iOS hands the user into the installed Goodreads app — breaking the import.iOS does not expose a way to predict UL handoff at the navigation delegate (UL is decided in WebKit's process before
decidePolicyForNavigationAction:fires). The only nav type iOS reliably does NOT consider for UL handoff is host-app-initiated[webView loadRequest:].This adds a
preventUniversalLinksprop (NSArray<NSString *>) for iOS. When set, top-frame navigations whose host matches an entry are canceled and re-issued via[webView loadRequest:]. Match is domain-suffix with a dot boundary, so passing'goodreads.com'coversgoodreads.com,www.goodreads.com, and any subdomain — but notevilgoodreads.com.Why a host list and not a boolean
Reissuing every top-frame nav unconditionally breaks anything that's not a plain GET:
NSURLRequestin the navigation action — reissuing strips the body and the server gets a body-less POST.Only hosts that actually have AASA registered for this app's bundle ID are at risk of UL handoff, so the app declares them. Hosts not in the list are unaffected.
Loop guard
[webView loadRequest:]triggersdecidePolicyForNavigationAction:again with the same URL. Without protection that's an infinite cancel/reissue loop. An associated-object flag on theWKWebViewinstance breaks the loop: the reissued nav re-enters with the flag set, the impl clears it and falls through to the normal handler.Files
src/WebViewTypes.ts— public TS prop typesrc/RNCWebViewNativeComponent.ts— Fabric codegen specapple/RNCWebViewImpl.h—@property NSArray<NSString *> *apple/RNCWebViewManager.mm—RCT_EXPORT_VIEW_PROPERTY(legacy bridge)apple/RNCWebView.mm— manual array conversion inupdateProps:(Fabric)apple/RNCWebViewImpl.m— the cancel/reissue logic indecidePolicyForNavigationAction:Test plan
na.account.amazon.comisn't in the sandbox list.🤖 Generated with Claude Code