Skip to content

fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads - #402

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
shenlvkang-collab:pr/webview-route-masking
Sep 14, 2026
Merged

Ark0N merged 2 commits into
Ark0N:masterfrom
shenlvkang-collab:pr/webview-route-masking

Conversation

@shenlvkang-collab

Copy link
Copy Markdown
Contributor

Summary

Stacked on #401 (its commit is the first in this branch; only the second commit is new). It fixes a bug that exists independently of #401: any single-page app opened as a web tab.

Symptom. A dashboard served through a web tab paints its HTML and CSS, then replaces them with its own "page not found" the moment its script runs. Reproduced with a minimal history-routed page: through the proxy, location.pathname is /webview/<cap>/, which no React Router / Vue Router / Vite dev-server app has a route for.

Fix, two halves.

  1. Route masking in the runtime shim. Before any page script runs, the shim rewrites the history entry to the path the page would see on its own origin (history.replaceState, which browsers allow inside the sandboxed frame — verified in Chromium). Only what the page reads changes: <base> still resolves relative URLs inside the prefix and every root-absolute sink is rewritten back into it. Because the masked document URL means the Referer-keyed 404 rescue can no longer help a request the shim misses, the remaining URL-taking entry points (Worker, SharedWorker, navigator.sendBeacon, window.open) are now covered by the shim as well.
  2. Lost-frame recovery. A navigation the page starts itself afterwards — location.reload() (a dev server's full-reload HMR), a root-absolute location.href — lands on Codeman's root with no capability anywhere (no prefix, no cookie in an opaque-origin frame, a Referer naming the masked page). It is recognised by shape only (Sec-Fetch-Dest: iframe, Accept: text/html, a path Codeman does not serve) and answered with a static page whose only script posts {type:'codeman:webview-lost', path} to the parent. The owning tab matches the frame by event.source (never by the payload), sanitises the path to a same-origin one, and remounts the frame inside the prefix at that path, bounded to 5 recoveries per minute per frame. The unauthenticated form is answered in the auth middleware before the credential checks, so a dev server that reloads on every save cannot rate-limit its own user out of Codeman; the authenticated form (Basic auth, trusted mode) is answered by the 404 handler. The page carries default-src 'none' plus the hash of its one script, and referrer: no-referrer.

This also turns the documented "root-absolute location navigation escapes the prefix" limit into a recovered case.

Verified end to end (Playwright, sandboxed frame, a history-routed test page): boots on / and its fetch('/api/data') succeeds; a reload inside the frame comes back routed on the path it had pushed; location.href = '/about' comes back on /about; a deep link opens on its path; no 4xx on the wire.

Docs: docs/web-tabs.md (layers 5–6, Known limits). Changeset: patch.

Test plan

  • test/webview-proxy.test.ts: masking (path/search/hash, landing page → /, non-prefixed document untouched), rewrites still applied after masking, window.open patch; isLostWebviewFrameNavigation shape checks; the recovery page's script hash matches its CSP.
  • test/webview-auth-exemption.test.ts: a lost frame gets the recovery page (200, HTML, CSP); never for /, a registered route, or a plain navigation; 20 recoveries do not trip the auth failure limit.
  • test/webview-loopback-links.test.ts: remount at the lost path inside the prefix, bare reload → landing page, foreign event.source / malformed payload ignored, //host/x cannot jump the frame, loop bounded.
  • Full test/webview-* set green; tsc --noEmit, eslint, prettier.

🤖 Generated with Claude Code

shenlvkang-collab and others added 2 commits September 10, 2026 12:48
…other device

An agent prints `http://localhost:5173/` (a dev server, a preview it just
served) and the user taps it on a phone. That address only exists on the
Codeman box, so the link was a guaranteed connection error from any other
device — while the web-tab proxy fetches from the server, where it works.

A loopback link (`localhost`, `*.localhost`, 127/8, 0.0.0.0, ::1) activated
in the terminal or clicked in the Response Viewer now opens as a proxied
web tab whenever the Codeman page itself is not on that box. A saved
proxied dashboard on the same origin is reused, with the link's own path,
query and fragment opened inside it (a mounted frame is navigated, not torn
down, so its state survives); otherwise one is saved under its host:port,
sandboxed like any other web tab, so it is in the Run dropdown next time.

Only loopback is routed this way. A LAN or tailnet address may well be
reachable from the device (a VPN, the same Wi-Fi) and a direct open is the
cheaper, richer path, so those keep opening in a new browser tab; on the
box itself every link opens directly. The terminal link provider and the
viewer's click handler consult one hook and fall through to their existing
behaviour when it declines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou
…d recover a frame that reloads

A dashboard served through a web tab saw `/webview/<cap>/` as its
`location.pathname`, and no app has a route for that: a React Router, Vue
Router or Vite dev-server page painted its HTML and CSS and then replaced
them with its own "page not found" the moment its script ran (reproduced
with a minimal history-routed page).

The proxy's runtime shim now rewrites the history entry to the path the
page would see on its own origin, before any page script runs. The base
element still resolves relative URLs inside the prefix and every root-
absolute sink is rewritten back into it, so only what the page READS
changes. With the document URL masked the Referer-keyed 404 rescue can no
longer help a request the shim misses, so the remaining URL-taking entry
points (`Worker`, `SharedWorker`, `navigator.sendBeacon`, `window.open`)
are covered by the shim as well.

A navigation the page starts itself afterwards — `location.reload()`
(a dev server's full-reload HMR), a root-absolute `location.href` — lands
on Codeman's root with no capability anywhere: no prefix in the path, no
cookie in an opaque-origin frame, a Referer naming the masked page. It is
recognised by shape (a top-level iframe navigation asking for HTML, for a
path Codeman does not serve) and answered with a static page whose only
script posts `{type:'codeman:webview-lost', path}` to the parent; the tab
that owns the frame (matched by `event.source`, never by the payload)
remounts it inside the prefix at that path, bounded per frame. The
unauthenticated form is answered in the auth middleware before the
credential checks, so a dev server that reloads on every save cannot
rate-limit its own user out of Codeman; the authenticated form (Basic
auth, trusted mode) is answered by the 404 handler.

Verified end to end against a history-routed page: boots on `/`, its
API call succeeds, a reload inside the frame comes back routed on the
path it had pushed, `location.href = '/about'` comes back on `/about`,
and a deep link opens on its path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Ark0N

Ark0N commented Sep 14, 2026

Copy link
Copy Markdown
Owner

This is the best-engineered of the batch and I want it in. Route masking is the right diagnosis: a history-routed app reading location.pathname on boot and finding /webview/<cap>/ was never going to route, and paying for it with a recovery path rather than giving up on masking is the harder and better call. Recognising the lost frame by shape alone, identifying it by event.source against mounted iframes rather than by anything in the payload, bounding recoveries per frame, and pinning the recovery page's CSP to the hash of its own script are all correct. I also checked the replaceState slice arithmetic (L.slice(P.length - 1) keeps the leading /, and L === P yields /) and the options.path semantics change from ''-means-no-navigate to null-means-no-navigate, where the only caller passes path || '/', so no regression there.

Two things before merge, one of them a one-liner.

1. The path sanitiser does not do what its comment says.

const path = data.path.replace(/^\/+/, '/');
void this.openWebview(id, { path: path.startsWith('/') && !path.startsWith('//') ? path : '/' });

The !startsWith('//') check is dead: the replace already collapsed those. What gets through is a backslash, because the WHATWG URL parser treats \ as / for special schemes:

in="/\\evil.com"  sanitized="/\\evil.com"  new URL(...,'https://dash.example/') -> https://evil.com/
in="//evil.com"   sanitized="/evil.com"    new URL(...) -> https://dash.example/evil.com

And the direct branch is reachable, not dead code: POST /api/webviews/:id/open returns { webview } with no embedUrl when embedMode === 'direct' (webview-routes.ts:275), so new URL(path, src).href runs for any direct-mode web tab. A page in one can remount its own frame on a foreign origin.

I do not think it is an escalation: that page could already location.href itself anywhere, and the remount carries no Codeman-origin access. But the comment promises "Path only, never an origin" and it does not hold, and test/webview-loopback-links.test.ts asserts //host/x, the form that already worked, while missing the form that does not. replace(/^[/\\]+/, '/') plus rejecting a second character of / or \, and a test case for the backslash.

2. This adds a third unauthenticated 200 to a password-protected install, and it needs to be written down where that is tracked. serveLostWebviewFrame() runs ahead of the credential checks in both the single-user and multi-user hooks. I am satisfied it is safe: tightly fenced (GET/HEAD, sec-fetch-dest: iframe|frame, mode navigate-or-absent, Accept: text/html, not /, /api/, /ws/, /q/, not a registered route), and the body is a static string with no reflected input under default-src 'none'. But CLAUDE.md's security table currently lists exactly two auth bypasses, and docs/web-tabs.md is not where anyone auditing that will look. Please add it to the security table and to docs/security-architecture.md.

While you are there, worth stating explicitly: those headers are trivially set by a non-browser client, so an unauthenticated caller can distinguish "registered route" (401) from "not a route" (200) and enumerate the route table. Our routes are public in docs/api-reference.md so I am not worried, but it should be a known property rather than a surprise.

3. Not blocking, but it belongs in Known limits. Masking trades away the Referer safety net, and only HTML is rewritten server-side (isHtmlContentType covers text/html and application/xhtml+xml). External stylesheets are fine, since a url() fetch carries the stylesheet's own URL as Referer and that is still inside the prefix. A root-absolute url() in an inline <style> is not: its Referer is the masked document, so it will 404 where tryWebviewRefererFallback used to rescue it. Narrow and testable, and better stated than discovered.

Housekeeping: the branch still carries #401's merged commit so GitHub reports +825/12f, but the merge-base delta is 424 lines across 9 files with one patch changeset, and feat-loopback-links-web-tab.md was consumed by a017e9a8 and is not re-added, so there is no changelog duplication. No rebase needed on my account.

Fix 1, add the docs for 2 and 3, and I will merge.

@Ark0N

Ark0N commented Sep 14, 2026

Copy link
Copy Markdown
Owner

One more before merge, and this one I reproduced against your head (349a89e) in Chromium, so I am adding it to the list from this morning.

A reload on the dashboard's landing page is the one navigation recovery does not cover, and it renders Codeman inside the web tab. The shim maps /webview/<cap>/ to exactly / (your own test pins it), and serveLostWebviewFrame() returns early for url === '/', so a location.reload() there requests Codeman's root as an iframe navigation. On a default passwordless install that is the static route, and the frame shows Codeman's own app shell; with a password it is the 401 your exemption test asserts. Either way no codeman:webview-lost message is posted, and because the document loaded fine the load handler clears webview-frame--failed, so the Reload / Open in new tab / Edit panel never appears either.

Measured with a proxied dummy dashboard in a sandboxed frame, no password:

A. opened through the proxy   frame href http://host/            body "DASH LANDING at /"
B. location.reload() there     frame href http://host/            title "codeman:tnode", body "Codeman Skip to terminal ..."   webview-lost: none
D. reload on /settings/users   frame href http://host/settings/users   body "DASH LANDING at /settings/users"           webview-lost: ["/settings/users"]

At the HTTP level GET / with Sec-Fetch-Dest: iframe answers 200 with the 249 KB app shell, while GET /settings/users with the same headers answers the 470-byte recovery page.

Before masking, the frame's document URL was /webview/<cap>/, so a reload re-requested the prefixed URL and worked, which makes this a regression rather than a gap. It is also the likeliest case in the PR's own motivating scenario: a Vite dev server usually sits on its landing page when it full-reloads (its client calls location.reload() on a config change or a failed HMR update). Your Playwright check covers a reload after a pushState, which is the case that works (D above).

Fix: make / recoverable. Nothing in Codeman frames Codeman's root (the only other iframes we create are the /api/... attachment and file previews in panels-ui.js and keyboard-accessory.js, and a foreign page cannot frame us), so admitting / when the request carries Sec-Fetch-Dest: iframe and no session cookie or Authorization header is narrow enough, and the frontend already handles the result, since a recovery with path: '/' is a case test/webview-loopback-links.test.ts covers. Please add the / case beside the existing ones in test/webview-auth-exemption.test.ts (the 401 assertion for a reload with a password becomes the recovery page) and a landing-page reload to the Playwright check.

So the list is: fix 1 from this morning, this one, and the docs for 2 and 3. Then I merge.

@Ark0N
Ark0N merged commit b0dddc9 into Ark0N:master Sep 14, 2026
2 checks passed
@Ark0N

Ark0N commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Merged, and shipping in 1.29.0: https://github.com/Ark0N/Codeman/releases/tag/codeman@1.29.0

The three items went in on master as follow-ups (d9364f52, 1306f731, 0ed63699), and two of them turned out to need more than the review asked for, which is worth knowing for next time:

  • the sanitiser: my proposed replace(/^[/\\]+/, '/') is not enough on its own, because the WHATWG parser deletes ASCII tab, CR and LF before parsing, so /<tab>/evil.com still resolved to https://evil.com/. It now strips those first, collapses a leading run of / or \ to one /, and refuses a second separator; the test drives the reachable direct-mode branch with all four forms.
  • the landing-page reload: admitting / in the middleware fixes the password-protected install, but a passwordless install registers no auth hook at all, so the index route needed the same gate (isLostWebviewRootFrame) before rendering the shell. Both are covered, the second one by a test booting a real passwordless server.
  • the recovery page is in CLAUDE.md's security table and docs/security-architecture.md as the third unauthenticated 200, with the route-enumeration property stated, and the inline <style> url() case is in Known limits.

Still the best-engineered PR of the batch: recognising the lost frame by shape, matching it by event.source, bounding recoveries and hashing the recovery script into its own CSP were all right the first time. Thanks.

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.

2 participants