From 349a89ec3babe16783c98ff2dbd205404dfe64a9 Mon Sep 17 00:00:00 2001 From: shenlvkang-collab Date: Thu, 10 Sep 2026 14:13:15 +0800 Subject: [PATCH] fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dashboard served through a web tab saw `/webview//` 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 --- .changeset/fix-webview-route-masking.md | 18 +++++ docs/web-tabs.md | 25 ++++++- src/web/middleware/auth.ts | 36 ++++++++- src/web/public/webview-tabs.js | 53 +++++++++++++- src/web/server.ts | 10 +++ src/web/webview-proxy.ts | 86 +++++++++++++++++++++- test/webview-auth-exemption.test.ts | 38 ++++++++++ test/webview-loopback-links.test.ts | 71 ++++++++++++++++++ test/webview-proxy.test.ts | 97 +++++++++++++++++++++++++ 9 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 .changeset/fix-webview-route-masking.md diff --git a/.changeset/fix-webview-route-masking.md b/.changeset/fix-webview-route-masking.md new file mode 100644 index 00000000..6f542d7a --- /dev/null +++ b/.changeset/fix-webview-route-masking.md @@ -0,0 +1,18 @@ +--- +"aicodeman": patch +--- + +fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads + +A dashboard served through a web tab saw `/webview//` 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. +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, while every URL the page emits still goes through +the existing rewrite layers (plus `Worker`, `sendBeacon` and `window.open`, which the masked +Referer can no longer rescue). A navigation the page starts itself afterwards — a dev +server's full-reload HMR, a root-absolute `location.href` — lands on Codeman's root with no +capability; it is recognised by shape (an iframe navigation asking for HTML for a path Codeman +does not serve), answered with a static page that tells the owning tab which path was lost, +and the tab remounts the frame inside the prefix at that path. That answer is served before +the credential checks, so it never counts as a failed login. diff --git a/docs/web-tabs.md b/docs/web-tabs.md index 753a607d..7f6be80e 100644 --- a/docs/web-tabs.md +++ b/docs/web-tabs.md @@ -147,6 +147,21 @@ layers cooperate so a dashboard talking to its own backend just works: using its `Referer` to identify the dashboard. This only fires for a request that already missed every Codeman route, and never for one that resolves to a real route, which is what keeps it from being an authentication bypass. +5. The same script **masks the proxy prefix off the page's own URL** before any + of the page's code runs (`history.replaceState` to the path the page would see + on its own origin). A single-page app routes on `location.pathname` at boot, + and `/webview//` is a path no app has a route for: without this, a React + Router / Vue Router / Next dev server painted its HTML and CSS and then replaced + them with its own "page not found" the moment its script ran. The page only + *reads* the masked path; every URL it emits still goes through the layers above. +6. A navigation the page starts **itself** after that — `location.reload()` (a dev + server's full-reload HMR), a root-absolute `location.href = '/login'` — now + targets Codeman's root with no capability anywhere on it. Codeman recognises + that request by shape (a top-level `