From 1306f731cfb2895993162964f0efac0b3cd49f26 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 14 Sep 2026 23:38:21 +0200 Subject: [PATCH] fix(webview): recover a proxied dashboard that reloads on its landing page The runtime shim masks `/webview//` off a proxied page's URL so its router boots on the path it expects, and the landing page masks to exactly `/`. A `location.reload()` there (a Vite dev server on a config change or a failed HMR update, the likeliest case in the feature's own motivating scenario) therefore asks for Codeman's root as an iframe navigation. `serveLostWebviewFrame()` returned early for `/`, so on a passwordless install the frame received Codeman's own app shell and rendered it inside the web tab, and with a password it got a 401 in the frame. Either way no `codeman:webview-lost` message was posted, and because the document loaded fine the load handler cleared the failed-frame panel, so the Reload / Open in new tab affordances never appeared. Before masking the frame's URL was the prefixed one, so a reload worked; this was a regression. `/` is the one lost-frame path a registered route also serves, so the route table cannot tell that reload from a real navigation. Credentials can: nothing in Codeman frames its own root, and a sandboxed frame is opaque-origin with no cookie and no Authorization header. `carriesAuthCredentials()` (pure, in webview-proxy.ts) makes that test, and `/` is now admitted by the auth hook only when it fails; a framed `/` that does carry credentials still gets the shell. Without a password no auth hook runs at all, so the index route applies the same test itself (`isLostWebviewRootFrame`) before rendering the shell, and the three places that emitted the recovery page share `sendLostWebviewFramePage()`. Tests: the password form in webview-auth-exemption (recovery page for a credential-free framed `/`, shell with valid Basic auth, 401 with a stale cookie or a top-level navigation), the passwordless form against a real WebServer in webview-lost-root-frame (port 3198), and the credential predicate in webview-proxy. All three fail without the fix. Verified against a live isolated instance as well: a framed `GET /` with no credentials answers the 470-byte recovery page, a top-level `GET /` and a framed one carrying a cookie answer the shell. Co-Authored-By: Claude Fable 5.1 --- src/web/middleware/auth.ts | 48 +++++++++++++++--- src/web/server.ts | 25 +++++++--- src/web/webview-proxy.ts | 29 ++++++++++- test/webview-auth-exemption.test.ts | 47 +++++++++++++++++- test/webview-lost-root-frame.test.ts | 74 ++++++++++++++++++++++++++++ test/webview-proxy.test.ts | 13 +++++ 6 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 test/webview-lost-root-frame.test.ts diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index f7df69d2..9dc78bcd 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -26,6 +26,7 @@ import { webviewCapabilities } from '../../webview-capabilities.js'; import { capabilityFromProxyPath, capabilityFromReferer, + carriesAuthCredentials, isLostWebviewFrameNavigation, lostWebviewFramePage, LOST_FRAME_PAGE_CSP, @@ -190,20 +191,55 @@ function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean * login challenge inside the tab nor counts as a failed attempt against the * caller's IP — a dev server that full-reloads on every save would otherwise * rate-limit its own user out of Codeman. Fenced like the Referer exemption: a - * path that resolves to a real route (the app shell, /api, /q) is never answered - * this way, so a genuine unauthenticated navigation still gets the 401. + * path that resolves to a real route (/api, /q, a registered handler) is never + * answered this way, so a genuine unauthenticated navigation still gets the 401. + * + * `/` is the one registered route that IS answered here, and only when the + * request carries neither the session cookie nor an Authorization header. The + * shim maps `/webview//` to exactly `/`, so a dashboard that reloads on its + * landing page (a Vite dev server on a config change) asks for Codeman's root + * as an iframe navigation; answering that with the app shell put Codeman inside + * its own web tab, and with a password it was a 401 in the frame. Nothing in + * Codeman frames its own root and the sandboxed frame has no credentials, so the + * credential-free form can only be that frame; a framed `/` WITH credentials is + * still the shell. Property worth knowing: a non-browser client can set these + * headers too, so an unauthenticated caller can tell a registered route (401) + * from a non-route (200) and enumerate the route table. Accepted, because the + * routes are public in docs/api-reference.md. * * @returns true when the reply was sent. */ function serveLostWebviewFrame(req: FastifyRequest, reply: FastifyReply): boolean { if (!isLostWebviewFrameNavigation(req)) return false; const url = (req.url ?? '').split('?')[0]; - if (url === '/' || url.startsWith('/api/') || url.startsWith('/ws/') || url.startsWith('/q/')) return false; - if (matchesRegisteredRoute(req, url)) return false; + if (url.startsWith('/api/') || url.startsWith('/ws/') || url.startsWith('/q/')) return false; + if (url === '/') { + if (carriesAuthCredentials(req.headers, AUTH_COOKIE_NAME)) return false; + } else if (matchesRegisteredRoute(req, url)) { + return false; + } + sendLostWebviewFramePage(reply); + return true; +} + +/** + * The landing-page case of serveLostWebviewFrame, for the index route. Without + * CODEMAN_PASSWORD no auth hook runs at all, so a lost frame's reload of `/` + * reaches `GET /` directly and the route asks this before rendering the shell. + * Under a password the hook has already answered a credential-free lost frame, + * so here it only ever sees the credentialed form, which stays the shell. + */ +export function isLostWebviewRootFrame(req: FastifyRequest): boolean { + if (!isLostWebviewFrameNavigation(req)) return false; + if ((req.url ?? '').split('?')[0] !== '/') return false; + return !carriesAuthCredentials(req.headers, AUTH_COOKIE_NAME); +} + +/** Send the static recovery page (lostWebviewFramePage) with its own CSP, uncached. */ +export function sendLostWebviewFramePage(reply: FastifyReply): FastifyReply { reply.header('content-security-policy', LOST_FRAME_PAGE_CSP); reply.header('cache-control', 'no-store'); - reply.type('text/html; charset=utf-8').send(lostWebviewFramePage()); - return true; + return reply.type('text/html; charset=utf-8').send(lostWebviewFramePage()); } /** diff --git a/src/web/server.ts b/src/web/server.ts index 5035013b..1b5287af 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -148,7 +148,13 @@ import { getLatestPlanUsage, setLatestCodexPlanUsage } from './plan-usage-latest import { telemetrySignature } from '../usage-telemetry.js'; import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js'; import type { ScheduledRun } from './ports/index.js'; -import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js'; +import { + registerAuthMiddleware, + registerSecurityHeaders, + registerHostGuard, + isLostWebviewRootFrame, + sendLostWebviewFramePage, +} from './middleware/auth.js'; import { isMultiUserMode } from '../config/multiuser.js'; import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js'; import { installRouteErrorHandler } from './route-error-handler.js'; @@ -181,7 +187,7 @@ import { registerTabLayoutRoutes, tryWebviewRefererFallback, } from './routes/index.js'; -import { isLostWebviewFrameNavigation, lostWebviewFramePage, LOST_FRAME_PAGE_CSP } from './webview-proxy.js'; +import { isLostWebviewFrameNavigation } from './webview-proxy.js'; import { CronService } from '../cron/cron-service.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -805,7 +811,14 @@ export class WebServer extends EventEmitter { // Security headers + CORS registerSecurityHeaders(this.app, this.https, this.basePath); - this.app.get('/', async (_req, reply) => { + this.app.get('/', async (req, reply) => { + // A web-tab frame that reloaded on its dashboard's landing page. The proxy's + // runtime shim maps `/webview//` to exactly `/`, so that reload asks for + // Codeman's own root as an iframe navigation, and it used to get the app + // shell rendered inside the web tab. Only the credential-free form is taken + // (nothing in Codeman frames its root; the sandboxed frame has no cookie and + // no Authorization); under a password the auth hook has answered it already. + if (isLostWebviewRootFrame(req)) return sendLostWebviewFramePage(reply); return reply .header('Cache-Control', 'no-cache') .type('text/html; charset=utf-8') @@ -981,11 +994,7 @@ export class WebServer extends EventEmitter { // that navigated itself off its proxy prefix: the runtime shim masks the // prefix so the page's router sees its own path, and a reload of that page // lands here. The unauthenticated form is answered in the auth middleware. - if (!req.url.startsWith('/api') && isLostWebviewFrameNavigation(req)) { - reply.header('content-security-policy', LOST_FRAME_PAGE_CSP); - reply.header('cache-control', 'no-store'); - return reply.type('text/html; charset=utf-8').send(lostWebviewFramePage()); - } + if (!req.url.startsWith('/api') && isLostWebviewFrameNavigation(req)) return sendLostWebviewFramePage(reply); if (req.url.startsWith('/api')) { return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound)); } diff --git a/src/web/webview-proxy.ts b/src/web/webview-proxy.ts index 133be3a8..1b707306 100644 --- a/src/web/webview-proxy.ts +++ b/src/web/webview-proxy.ts @@ -734,7 +734,9 @@ export const LOST_FRAME_PAGE_CSP = `default-src 'none'; script-src 'sha256-${LOS * no capability anywhere on it: no prefix in the path, no cookie in an * opaque-origin frame, and a Referer that names the masked page. Such a request * is recognisable by shape alone: a top-level navigation of an `