mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
fix(webview): recover a proxied dashboard that reloads on its landing page
The runtime shim masks `/webview/<cap>/` 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<cap>/` 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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user