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 `