fix(security): harden navigation guards against backslash open-redirects
AF-1 (dual-lane adversarial review, 2026-05-19): the three new navigation
guards introduced in this branch — `pathUtils.ensureAppRoot`,
`navigationUtils.SAFE_NAVIGATION_URL_RE`/`assertSafeNavigationUrl`, and
`RedirectWarning/utils.isAllowedScheme` — all only screened the leading
`//` form of a protocol-relative URL. Backslash variants (`/\evil.com`,
`\/evil.com`, `\\evil.com`) slipped past each: the regex's `^\/(?!\/)`
matched `/\` (2nd char is `\`, not `/`); `ensureAppRoot` and
`isAllowedScheme` only tested `startsWith('//')`; and `new URL('/\\…')`
throws, routing through `isAllowedScheme`'s `catch { return true }`
"relative — allow" branch.
Browsers normalise `/\` → `//` in the special-scheme authority, so on a
default (root-of-domain, empty app root) deployment, a consented click
through `RedirectWarning` resolved to `https://evil.com`. Every channel-3
helper (`openInNewTab`, `AppLink`, `getShareableUrl`, `redirect`) was
defeated.
Slice 1 lands all five hardening changes in one commit (same-commit
coupling per PLAN.md regression-test gate) so no intermediate state
leaves the user-facing interstitial misleading the user:
- `pathUtils.ts:41` `ensureAppRoot`: protocol-relative check now matches
`/^[/\\][/\\]/`, so backslash variants are passed through unchanged
for the downstream guard to reject instead of being laundered through
the appRoot prefixing path.
- `navigationUtils.ts` `SAFE_NAVIGATION_URL_RE` / `assertSafeNavigationUrl`:
rejects any URL containing `\` anywhere, and adds an authority-userinfo
check (`new URL` parse → reject if `username` or `password` set) for
`http(s)`/`ftp` schemes (closes the `https://good@evil.com` variant,
nit-3).
- `navigationUtils.ts` `navigateTo` / `navigateWithState`: wired through
`assertSafeNavigationUrl` (closes C2 transitively + nit-1's ~13 direct
callers). `navigateTo` falls back to `ensureAppRoot('/')` with a
`console.error` on block; `navigateWithState` no-ops + `console.error`
(history API — no surprise full-page nav).
- `RedirectWarning/utils.ts:46` `isAllowedScheme`: backslash rejection
moved BEFORE the `new URL` try-block so a URL-constructor throw on
`/\evil.com` cannot fall through `catch { return true }`.
- `RedirectWarning/index.tsx`: when `isAllowedScheme(targetUrl)` returns
false, renders a visible "Unsafe link blocked" Card with no Continue
button instead of the standard "External link warning" Card. The
interstitial UI itself now refuses unsafe URLs rather than just
silently no-opping the click.
Regression oracle (HEAD-verified scenario, pinned across all four guard
test files): path `/\evil.com`, both empty and `/superset/` app roots,
must reject independently at each guard site; the composition pin
`assertSafeNavigationUrl(ensureAppRoot('/\\evil.com'))` (asserted
transitively through the public `getShareableUrl` / `openInNewTab`
helpers since `assertSafeNavigationUrl` is module-private) must throw.
86 unit + integration assertions across 4 test files; 23 failing on
HEAD `1613e53aaf`, all green at this commit.
Refs: PR #39925, PLAN.md Slice 1 (round 6 + AF folds).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> J
Joe Li committed
9141324715e89aced5911e13e60df0f895f3530f
Parent: 1613e53