Skip to content

Add preventUniversalLinks prop to sandbox hosts from UL handoff#9

Closed
artemlitch wants to merge 2 commits intoreadwise/v13.16.0from
artem/prevent-universal-links
Closed

Add preventUniversalLinks prop to sandbox hosts from UL handoff#9
artemlitch wants to merge 2 commits intoreadwise/v13.16.0from
artem/prevent-universal-links

Conversation

@artemlitch
Copy link
Copy Markdown
Collaborator

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 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.

<WebView
  source={{ uri: 'https://www.goodreads.com/user/sign_in' }}
  preventUniversalLinks={['goodreads.com']}
/>

Why a host list and not a boolean

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 — reissuing strips the body and the server gets a body-less POST.
  • Single-use OAuth codes (e.g. Amazon's callback URLs during the LWA flow) get double-consumed when the chain runs through other hosts.

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:] triggers decidePolicyForNavigationAction: again with the same URL. Without protection that's an infinite cancel/reissue loop. An associated-object flag on the WKWebView instance 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 type
  • src/RNCWebViewNativeComponent.ts — Fabric codegen spec
  • apple/RNCWebViewImpl.h@property NSArray<NSString *> *
  • apple/RNCWebViewManager.mmRCT_EXPORT_VIEW_PROPERTY (legacy bridge)
  • apple/RNCWebView.mm — manual array conversion in updateProps: (Fabric)
  • apple/RNCWebViewImpl.m — the cancel/reissue logic in decidePolicyForNavigationAction:

Test plan

  • Bookwise Goodreads import: source URL change + this prop + 3x rescan iterations including a fresh Amazon SSO login. Verified zero handoff to the installed Goodreads app across the entire flow.
  • No regression on the Amazon OAuth POST chain — credential submit and intermediate redirects pass through naturally because na.account.amazon.com isn't in the sandbox list.
  • Verify on a build without the prop set: app behaves identically to before this change (no-op when unset/empty).

🤖 Generated with Claude Code

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>
Copy link
Copy Markdown
Collaborator Author

@artemlitch artemlitch left a comment

Choose a reason for hiding this comment

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

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 preventUniversalLinks would 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.

Comment thread apple/RNCWebViewImpl.m
});
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
  }
}

Comment thread apple/RNCWebViewImpl.m
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;
  }
}

Comment thread src/WebViewTypes.ts
* pass through normally.
*
* @platform ios
*/
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.

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.
@artemlitch artemlitch closed this May 7, 2026
@artemlitch artemlitch deleted the artem/prevent-universal-links branch May 7, 2026 05:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant