fix(web-ui): serve /ui on Windows by comparing against the platform separator - #322
Merged
Conversation
…eparator
The path-traversal guard in the web-ui frontend tested containment with a
hardcoded `/`:
if (target !== DIST && !target.startsWith(`${DIST}/`))
`normalize()` emits the PLATFORM separator, so on Windows every legitimate
asset resolves to `C:\...\web\dist\index.html`, which does not start with
`C:\...\web\dist/`. The guard fired on valid files and the entire UI returned
403 Forbidden. Fixes #299.
Worth recording what the guard was actually doing: that 403 branch is
unreachable over HTTP. The WHATWG URL parser resolves `..` segments (and folds
`\` to `/` for http) before `handleFetch` sees the path, so `/ui/../../etc/passwd`
arrives as pathname `/etc/passwd` and falls out of the `/ui/` prefix check
entirely. The guard's only observable effect in production was this Windows
false positive. It is still correct defense-in-depth, so it is fixed rather
than dropped.
Rather than only swapping in `sep` — which no test on a POSIX CI machine can
verify — the mapping moves into a pure `resolveUiAsset(distRoot, urlPath,
pathFlavor)`, following the precedent already set by `buildBootScript` in this
module. The path flavor is injectable, so `path.win32` reproduces the Windows
behaviour from Linux and the regression is covered where it actually runs.
Tests assert both flavors: legitimate nested assets, the mount root with and
without a trailing slash, SPA deep links, `..` traversal, and a sibling
directory that merely shares the root's string prefix (`web/dist-secrets`).
Confirmed to catch the regression — reverting the guard to `${distRoot}/`
fails four win32 cases; restoring `${distRoot}${p.sep}` passes.
Verified end to end against a live local-mode daemon: `/ui` and `/ui/` 200,
both hashed bundle assets 200, SPA deep link 200, missing asset 404, four
traversal payloads (including `%2f`-encoded) never escaping dist, and the
injected local token driving a real WebSocket through auth.ok to a session
list.
Reported by @yl-dev-tmtc, who also identified the cause and the `sep` fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rsharath
approved these changes
Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #299.
The bug
The path-traversal guard in
src/frontends/web-ui/index.tstested containment with a hardcoded/:normalize()emits the platform separator, so on Windows every legitimate asset resolves toC:\...\web\dist\index.html— which does not start withC:\...\web\dist/. The guard fired on valid files and the whole UI returned403 Forbidden.What the guard was actually doing
Worth recording, because it changes how you read this diff: that 403 branch is unreachable over HTTP. The WHATWG URL parser resolves
..segments (and folds\to/for http) beforehandleFetchever sees the path:Both fall out of the
/ui/prefix check entirely. So the guard's only observable effect in production was this Windows false positive. It is still correct defense-in-depth, so this fixes it rather than dropping it.The fix
The reporter's one-line
sepfix is right, but no test on a POSIX CI machine can verify it. So the mapping moves into a pureresolveUiAsset(distRoot, urlPath, pathFlavor)— the precedentbuildBootScriptalready set in this module. The path flavor is injectable, sopath.win32reproduces the Windows behaviour from Linux and the regression is covered where CI actually runs.handleFetchdrops to three lines.Tests
src/tests/web-ui-asset-path.test.ts, 14 cases across both flavors: legitimate nested assets, the mount root with and without a trailing slash, SPA deep links,..traversal, and a sibling directory that merely shares the root's string prefix (web/dist-secrets).Confirmed to catch the regression — reverting the guard to
${distRoot}/fails four win32 cases; restoring${distRoot}${p.sep}passes.Verification
Full suite 2443 pass / 0 fail;
typecheckandbiomeclean.End to end against a live local-mode daemon (
bun src/cli.ts start --local):/ui,/ui/text/html/ui/assets/index-*.jstext/javascript, 498706b/ui/assets/index-*.csstext/css, 52480b/ui/sessions/abc(SPA deep link)text/html/ui/assets/nope.js%2f-encodeddistThen the real UI path: harvested the injected
__CODEOID_LOCAL_TOKEN__from the served HTML, opened the WebSocket,auth.ok,session.list.result→ 4 sessions.Not verified on Windows hardware — I have no Windows host here. The win32 coverage is
path.win32semantics, which is precisely where the bug lived; @yl-dev-tmtc confirmed the equivalentsepfix resolves it on a real Windows machine.Thanks to @yl-dev-tmtc for reporting this with the cause and the fix already identified.
🤖 Generated with Claude Code