Skip to content

fix(discover): keep frames inside a page from opening links to other apps - #1065

Draft
CassioMG wants to merge 1 commit into
cg-walletkit-upgradefrom
cg-discover-subframe-links
Draft

CassioMG wants to merge 1 commit into
cg-walletkit-upgradefrom
cg-discover-subframe-links

Conversation

@CassioMG

@CassioMG CassioMG commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

Stacked on #1064. In Discover, a frame embedded in a page can no longer open a link to another app on its own, our own pairing links included. A link that loads into the page itself still opens, as before.

react-native-webview sends any link that isn't http(s) straight to the OS, before our navigation handler sees it, wherever it loads. So a frame inside a page could open a pairing link or another app without the user doing anything. Discover now asks the WebView about every URL and decides itself:

  • http(s) pages, and about:blank for empty frames, load as before.
  • A link to another app that loads into the page itself still goes to the OS, so pairing from Discover is unchanged.
  • The same link loaded into a frame inside the page is blocked. Schemes that can run code stay blocked, as before.
  • A tab in the background loads pages only and opens nothing outside Discover.

Limits:

  • iOS: the WebView says which frame a URL loads into, not which frame started it. A link in a frame with target="_top", which the user has to tap, therefore counts as the page's own. On the iPhone SE simulator, such a link and a frame's window.open opened nothing either before or after this change. That check is inconclusive, though: by then even a plain page link wasn't pairing on that simulator (see the verification notes).
  • Android: react-native-webview's Android client doesn't say which frame a URL loads into. It drops isForMainFrame() and sends no isTopFrame, so on Android every navigation still counts as the page's own. On the emulator, a frame's pairing link still starts pairing, as before. Closing that needs a small native patch to react-native-webview, or an upstream change, so that the event carries request.isForMainFrame(). That's left out of this PR.

Lines changed

Kind Files Lines added Lines removed
Source code 3 111 22
Tests 1 96 0
Other 0 0 0
Total 4 207 22

Only this PR's own changes, not those of the PRs it's stacked on.

Screenshots (iPhone SE simulator on iOS 27.2, Pixel 10 Pro emulator)

A local test page, opened in Discover, carries a real pairing link from a local WalletConnect test client. One button points an embedded frame at the link, another is the link itself, and a third runs five alert() calls.

Step iOS Android
The test page in Discover
The frame navigates to the pairing link: blocked, nothing opens Not blocked on Android (see above)
The page's own pairing link: pairing starts as before (here Blockaid can't scan the local page, so its notice comes first)
A page alert covers the whole app until it's dismissed (unchanged, reported only)
Implementation details (for agents)

What changed:

  • src/helpers/browser.ts:
    • getDiscoverNavigationAction({ url, isTopFrame, pairingLink }) returns load, openOutside or block.
    • openLinkOutsideDiscover(url) does what react-native-webview did: Linking.canOpenURL, then Linking.openURL. A failure is logged with the link's scheme only, since React Native's error message repeats the whole link.
  • src/components/screens/DiscoveryScreen/components/WebViewContainer.tsx: originWhitelist={["*"]}, so every URL reaches onShouldStartLoadWithRequest. A background tab's handler allows load only.
  • src/components/screens/DiscoveryScreen/DiscoveryScreen.tsx: the handler asks the helper and opens openOutside links through openLinkOutsideDiscover. A frame's blocked link is logged with its scheme only.
    • The existing check that keeps a page URL carrying our pairing link from loading stays.
    • Our own custom-scheme pairing links no longer reach that check, which would have blocked them. They go to the OS, which hands them to the deep-link handler as before.

/** What Discover's WebView does with a navigation. */
export type DiscoverNavigationAction = "load" | "openOutside" | "block";
/**
* Decides what Discover's WebView does with a navigation. The WebView hands
* every URL to our handler, links to other apps included; react-native-webview
* used to send those to the OS itself, before our handler saw them.
*
* - `load`: an http(s) page, or `about:blank` for an empty frame.
* - `openOutside`: any other scheme loaded into the page itself goes to the
* OS, as before. That's how pairing from Discover works.
* - `block`: a scheme that can run code; a page URL that carries the wallet's
* pairing link; and any other scheme loaded into a frame inside the page,
* which could otherwise open an app, pairing included, without the user
* doing anything.
*
* `isTopFrame` says whether the navigation loads into the page itself or into
* a frame, not which of them started it: a link with `target="_top"` in a
* frame, which the user has to tap, counts as the page's. Android doesn't say
* (`isTopFrame` is undefined there), so there every navigation counts as the
* page's own, as before.
*
* @param navigation.url - The URL about to load
* @param navigation.isTopFrame - False when the URL loads into a frame inside the page
* @param navigation.pairingLink - The wallet's pairing link, e.g. `freighterwallet://wc-redirect`
* @returns What to do with the navigation
*/
export const getDiscoverNavigationAction = ({
url,
isTopFrame,
pairingLink,
}: {
url: string;
isTopFrame?: boolean;
pairingLink: string;
}): DiscoverNavigationAction => {
if (isDangerousScheme(url)) {
return "block";
}
// What react-native-webview loads by default.
if (/^https?:\/\//.test(url) || url.startsWith("about:blank")) {
return url.includes(pairingLink) ? "block" : "load";
}
return isTopFrame === false ? "block" : "openOutside";
};
/**
* Opens a link to another app from Discover the way react-native-webview did:
* only if the OS says it can. A failure is logged with the link's scheme
* only, since the link (and React Native's error message, which repeats it)
* can carry a pairing URI.
*
* @param url - The link to open
*/
export const openLinkOutsideDiscover = (url: string): Promise<void> =>
Linking.canOpenURL(url)
.then(async (canOpen) => {
if (canOpen) {
await Linking.openURL(url);
}
})
.catch(() => {
logger.warn("Discover", "Failed to open a link outside Discover", {
scheme: url.split(":")[0],
});
});

const handleShouldStartLoadWithRequest = useCallback(
(request: DiscoverNavigationRequest) => {
logger.debug(
"WebViewContainer",
"onShouldStartLoadWithRequest, request:",
request,
);
const action = getDiscoverNavigationAction({
url: request.url,
isTopFrame: request.isTopFrame,
pairingLink: WALLET_KIT_MT_REDIRECT_NATIVE,
});
if (action === "openOutside") {
// Pairing links come back to the app through the deep-link handler
// (useWalletKitEventsManager), as other apps' links go to them.
openLinkOutsideDiscover(request.url);
} else if (action === "block" && request.isTopFrame === false) {
logger.warn(
"WebViewContainer",
"Blocked a frame inside the page from opening a link outside Discover",
{ scheme: request.url.split(":")[0] },
);
}
return action === "load";
},
[],
);

Verification:

  • yarn test: 356 suites pass, 5,327 tests pass and 6 are skipped. New tests cover:
    • each navigation decision: pages and about:blank load in the page or a frame; code-running schemes and a page URL carrying the pairing link are blocked; other schemes go to the OS when they load into the page itself (or when the platform doesn't say), and are blocked in a frame;
    • the open-outside helper, including that a failure logs the scheme and not the link.
  • yarn lint:ts passes, and ESLint and Prettier pass on every changed file.
  • Dev builds on an iPhone SE (3rd generation) iOS 27.2 simulator and a Pixel 10 Pro emulator, with a local test page and a local WalletConnect test client (screenshots above):
    • iOS: the frame's pairing link is blocked, and the page's own link still starts pairing.
    • Android: both start pairing, as before.
  • A later iOS run was inconclusive. It checked a page's window.open and target="_blank" link, and a cross-origin frame's window.open and target="_top" link, with and without this change. None opened anything, but by then a plain page link, which paired in the first run, didn't pair either. Outside deep links kept pairing, and the same happened without this PR's code. So the cause on that simulator is still open.
  • Dialog loops (to check and report only): on both platforms, a page's alert() or confirm() covers the whole app until it's dismissed, so the tab bar can't be reached. Discover keeps its tabs across launches, so a page that loops alerts without end needs a force-quit and comes back on the next visit to Discover. Not changed here.

🤖 Generated with Claude Code

…apps

react-native-webview sent any link that isn't http(s) to the OS itself,
before Discover's navigation handler saw it, wherever it loaded, so a
frame embedded in a page could open a pairing link or another app
without the user doing anything. Discover now asks the WebView for every
URL and decides itself: pages and about:blank load; a link to another
app loaded into the page itself still goes to the OS, so pairing from
Discover is unchanged; the same link loaded into a frame inside the page
is blocked. A background tab loads pages only. Android doesn't say which
frame a URL loads into, so there every navigation counts as the page's
own, as before. A link that can't be opened is logged with its scheme
only, since it can carry a pairing URI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-986879272efa54463db5
Backend: sandbox (CassioMG). SDF collaborators only — install instructions in the release description.

@github-actions

Copy link
Copy Markdown
Contributor

Android Emulator preview APK is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-893a02cd23a8c17dfeb7
Backend: sandbox (CassioMG). SDF collaborators only — install instructions in the release description.

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