Skip to content

fix: stop resolveHref/gotoHref throwing on bracketed paths - #327

Open
FrameAutomata wants to merge 1 commit into
mainfrom
fix/325-resolve-href-brackets
Open

fix: stop resolveHref/gotoHref throwing on bracketed paths#327
FrameAutomata wants to merge 1 commit into
mainfrom
fix/325-resolve-href-brackets

Conversation

@FrameAutomata

Copy link
Copy Markdown
Collaborator

Closes #325.

resolve() from $app/paths takes a route id and populates its [param] segments by dereferencing a params argument. Both helpers handed it a concrete pathname and no params, so any path containing brackets threw. Reproduced against the installed kit 2.49.2:

"/a[b]c"        -> TypeError: Cannot read properties of undefined (reading 'b')
"/tasks/[task]" -> TypeError: Cannot read properties of undefined (reading 'task')
"/issues/abc"   -> "/issues/abc"

safeLocalPath only requires a single leading /, so /login?returnTo=%2Fa%5Bb%5Dc reaches it. In login/+page.svelte the throw lands after authState.setToken(...), and the form's own catch renders it as a credentials error — the user ends up logged in, still on the login page, reading Cannot read properties of undefined (reading 'b'). auth/callback has the same shape.

The fix

These hrefs are already concrete, so they need the base path, not route resolution — which is also what goto() documents wanting ("if you've set paths.base and the URL is root-relative, you need to prepend the base path"). gotoHref now delegates to resolveHref instead of repeating its body; that duplication is why one bug lived in two places. The as '/' cast that suppressed the type error is gone.

The scheme-only guard is worse than "missing base prefix"

The old guard required //, so mailto:/tel: fell through to resolve(). That doesn't just prepend a base — get_route_segments does route.slice(1) unconditionally, assuming a leading /:

'mailto:support@example.com'  ->  '/ailto:support@example.com'

It eats the first character of any href without a leading slash. Latent — nothing in src/ passes one today — but button.svelte:70 and otel-setup-steps.svelte:108 forward hrefs they don't control. The guard is now /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.

Why no test caught this

src/test/mocks/app-paths.ts stubbed resolve as path => path — identity. Any test written against it passes pre- and post-fix and pins nothing. The alias now points at SvelteKit's real client module, so the new tests fail on the unfixed code for the right reason:

× passes bracketed paths through instead of throwing
    TypeError: Cannot read properties of undefined (reading 'b')
× leaves anything that is not an app path alone
    expected '/ailto:support@example.com' to be 'mailto:support@example.com'
× navigates to a bracketed path instead of throwing
    TypeError: Cannot read properties of undefined (reading 'b')

Two approaches that don't work, so they aren't re-tried: the sveltekit() plugin resolves $app/paths to the server variant and breaks the three flame-graph component tests, and a bare deep import is blocked by kit's exports map. $app/navigation still needs a stub (goto() requires a mounted router); it throws rather than no-ops so an unmocked navigation fails loudly.

Performance

Incidental but measured, since resolveHref runs per <a> per render. Old path allocated ~4 arrays and ran a per-segment regex replace; new path is two concatenations.

per call
old (resolve()) 230.8 ns
new (concat) 32.8 ns

Two calls worth a reviewer's opinion

1. The vitest alias reaches into kit's src/. node_modules/@sveltejs/kit/src/runtime/app/paths/client.js is not in the package's exports map, so a kit reshuffle breaks it. I kept it anyway: with a base-only stub, pre-fix code fails every resolveHref assertion with "resolve is not a function" rather than isolating the bracket defect, and the failure mode on upgrade is loud (unresolved import), not silent. Reasonable to overrule — reverting costs the diagnostic quality above and nothing else, since no module under test calls resolve() today.

2. finish-setup/+page.svelte will conflict with #324. On main that line is goto(returnTo) — no crash, but it drops the base path and is one of #316's 10 lint errors. #324 fixes it by hand-inlining goto(base + returnTo) with its own suppression and a near-verbatim copy of this PR's comment. Routing it through gotoHref instead means this branch doesn't ship the general mechanism while a special case sits open-coded next door. The conflict is one line, take-mine, and it drops project-wide eslint errors from 10 to 9.

Known and deliberately not fixed

  • gotoHref double-prefixes base when a caller passes an already-resolved href — +error.svelte:18 and post-mortems-tab.svelte:156 pass resolve() output through createRowClickHandler; url-params.ts:113 and +layout.svelte:227 pass window.location.pathname. Pre-existing and unchanged: the old gotoHref called resolve(), which prepends base identically. Fixing it is a policy decision across ~8 sites — the base-path job fix: let npm run lint join CI #324 scoped out.
  • dashboards/+page.svelte:950resolve(page.url.pathname as '/'), same cast, re-applies base to a pathname that already has it. Brackets unreachable (static route). Untouched because fix: let npm run lint join CI #324 already edits that file. Note this is a different site from the setServerScope one fix: let npm run lint join CI #324 fixes.
  • resolve() silently collapsed /issues//foo to /issues/foo; the new code preserves the doubled slash. Nothing produces one (addStickyParamsToHref normalizes via new URL), so no collapse was added back.

All other non-literal resolve() call sites were checked: status-page slugs are backend-validated against ^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$, and the rest either wrap the value in encodeURIComponent (which percent-encodes brackets) or interpolate DB ids/hashes.

Verification

npm run test 29 passed (5 files) · npm run check 0 errors, 12 warnings unchanged · npm run build ok · eslint/prettier clean on changed files · project-wide eslint 9 errors, down from main's 10.

Note npm run lint still fails on this base — those are #316's pre-existing prettier/eslint failures, fixed by #324.

🤖 Generated with Claude Code

resolve() from $app/paths takes a route id and populates its [param]
segments by dereferencing a params argument. Both helpers handed it a
concrete pathname and no params, so any path containing brackets threw
`TypeError: Cannot read properties of undefined (reading '<name>')`.

safeLocalPath lets brackets through, so /login?returnTo=%2Fa%5Bb%5Dc
reaches it. In login/+page.svelte the throw lands after the token is
stored, and the form's own catch renders it as a credentials error --
the user ends up logged in, still on the login page, reading a
TypeError. auth/callback has the same shape.

These hrefs are already concrete, so they need the base path, not route
resolution. That is also what goto() documents wanting for root-relative
URLs. gotoHref now delegates to resolveHref rather than repeating it,
and finish-setup routes its SSO returnTo through gotoHref instead of
open-coding a bare goto() that drops the base path.

Also widens the passthrough guard, which required '//' and so let
scheme-only URIs through to resolve(). That is not merely a missing base
prefix: resolve_route does route.slice(1) unconditionally, so it ate the
first character -- 'mailto:x@y.z' came back as '/ailto:x@y.z'. Latent
today; nothing in src/ passes one, but button.svelte and
otel-setup-steps.svelte forward hrefs they do not control.

The $app/paths test stub returned its argument unchanged, which is why
no test ever saw this. It is replaced by SvelteKit's real client module
so the new tests fail on the unfixed code.

Closes #325
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.

resolveHref/gotoHref throw a TypeError on any path containing brackets, stranding SSO and returnTo logins

1 participant