Compare commits

...
Author SHA1 Message Date
Codeman maintainer af9db455ff chore: version packages
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:50:41 +02:00
14 changed files with 285 additions and 48 deletions
+14
View File
@@ -1,5 +1,19 @@
# aicodeman
## 1.9.1
### Patch Changes
- Narrow the Run dropdown, and close the last two gaps in web-tab asset rewriting.
**The Run dropdown was pinned at its full width.** It capped at 300px, and the recent-session rows wanted 326px, so it always rendered at the cap and reached further across the terminal than it needed to. Now 250px, chosen as the width at which a `~/<dir>/<repo>` + timestamp row still fits whole, since identifying a session to resume is what that list is for. Three fixes were needed to make the narrower menu degrade instead of clip: the saved-URL label now has its own element, because `text-overflow` on the row button did nothing (a bare text node inside a flex container becomes an anonymous flex item that ellipsis cannot reach); `.hist-dir` got `min-width: 0`, without which a flex item refuses to shrink below its own text and pushes the date out of the box; and history rows are held to the container width, because the list's `overflow-y: auto` implicitly makes `overflow-x: auto` and let each row size to its own content and scroll sideways. Phone and tablet widths are unchanged, being set separately in `mobile.css`.
**A dashboard's own `/api/...` assets are relayed again.** The `Referer`-keyed 404 fallback, which rescues a root-absolute asset that no rewrite layer could reach, refused everything under `/api` outright. Dashboards commonly serve their assets from exactly that namespace, so those requests had no rescue at all. The refusal is now precise: the relay runs before the API-shaped 404, and the auth exemption refuses only paths that resolve to a REAL Codeman route, with `/ws/` and `/q/` still refused by prefix.
Two findings shaped that fence, both from probing Fastify rather than reading it. `hasRoute()` matches the registered PATTERN literally, so `/api/sessions/abc` reports no match against a registered `/api/sessions/:id` and would have granted an unauthenticated exemption on a live session-scoped route; `findRoute()` performs the real lookup and is what the fence uses. And `@fastify/static` is mounted at `/`, so it registers a root catch-all matching every path, which has to count as "no real route" or the fence would refuse every referer-form request and break the rescue that already worked. A root catch-all is distinguishable because it is the only route whose wildcard param comes back equal to the whole request path. The fence fails closed, and both edges are pinned in `test/webview-auth-exemption.test.ts`.
**`url()` inside runtime CSS is rewritten.** Measuring the fallback against a purpose-built dashboard showed one sink no relay can reach: a `<style>` element built by page script has no URL of its own, so the browser sends an EMPTY `Referer` with the image request it triggers. The injected URL shim now rewrites root-absolute `url()` in `<style>` blocks, both as markup and when a `<style>` node is inserted. Verified in Chromium: a stylesheet-only `/api/hero.png` and a runtime `<style>` `/api/late.png` both load, where both previously failed. The remaining known gap is self-navigation via `location.href`, which cannot be patched because `Location.href` is unforgeable.
## 1.9.0
### Minor Changes
+1 -1
View File
@@ -74,7 +74,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 1.9.0 (must match `package.json`)
**Version**: 1.9.1 (must match `package.json`)
## Project Overview
+2 -2
View File
@@ -111,13 +111,13 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
**Origin-scoped, not path-scoped.** `/webview/<cap>/x/y` always maps to `<upstream origin>/x/y`, never `<upstream origin><saved path>/x/y`. Dashboards reference assets root-absolutely (`/public/build/app.js`), so origin-scoping is the only mapping under which those resolve; the saved URL's own path+query is used solely as what `/webview/<cap>/` itself serves.
**The capability, and why the auth exemption is safe.** A sandboxed iframe (no `allow-same-origin`) is OPAQUE-ORIGIN, so every request it makes is cross-site: the `SameSite=lax` `codeman_session` cookie is never attached, and writes and WS upgrades arrive with `Origin: null`, which `isAllowedRequestOrigin` rejects by design. Cookie auth therefore cannot work. `src/webview-capabilities.ts` mints a 192-bit `randomBytes` token (memory-only, so a restart invalidates every outstanding one; rolling TTL; bound to the minting user; revoked on edit/delete) which `middleware/auth.ts` recognizes via `hasValidWebviewCapability()` to skip the cookie and Origin checks. ⚠️ The **Host allowlist is never bypassed**, so DNS-rebinding protection is intact. ⚠️ There is a second, `Referer`-keyed form of the exemption for root-absolute assets that `<base href>` cannot rewrite (`fetch('/api/data')`, `import('/chunk.js')`); it is the only exemption decided by a request-supplied header, so it is fenced to **safe methods on non-`/api`, non-`/ws`, non-`/q` paths**. Without that fence a page could present a webview Referer and skip auth on `/api`. `test/webview-auth-exemption.test.ts` pins every edge.
**The capability, and why the auth exemption is safe.** A sandboxed iframe (no `allow-same-origin`) is OPAQUE-ORIGIN, so every request it makes is cross-site: the `SameSite=lax` `codeman_session` cookie is never attached, and writes and WS upgrades arrive with `Origin: null`, which `isAllowedRequestOrigin` rejects by design. Cookie auth therefore cannot work. `src/webview-capabilities.ts` mints a 192-bit `randomBytes` token (memory-only, so a restart invalidates every outstanding one; rolling TTL; bound to the minting user; revoked on edit/delete) which `middleware/auth.ts` recognizes via `hasValidWebviewCapability()` to skip the cookie and Origin checks. ⚠️ The **Host allowlist is never bypassed**, so DNS-rebinding protection is intact. ⚠️ There is a second, `Referer`-keyed form of the exemption for root-absolute assets that `<base href>` cannot rewrite (`fetch('/api/data')`, `url(/img.png)` in a stylesheet); it is the only exemption decided by a request-supplied header, so it is fenced to **safe methods on paths that resolve to NO registered Codeman route** (`matchesRegisteredRoute`), plus a blanket refusal of `/ws/` and `/q/`. Without that fence a page could present a webview Referer and skip auth on a real API route. ⚠️ The fence uses `findRoute()`, NOT `hasRoute()`: `hasRoute` matches the registered PATTERN literally, so `/api/sessions/abc` reports false against `/api/sessions/:id` and would hand out an exemption on a live route. It also has to treat `@fastify/static`'s root catch-all (mounted at `/`, matches everything) as "no real route", which is detectable because a root catch-all is the only route whose `*` param equals the whole request path. `/api` used to be refused by prefix instead, which permanently broke dashboards serving their own assets from an `/api/...` namespace. `test/webview-auth-exemption.test.ts` pins every edge.
**Sandbox default.** The iframe carries `allow-scripts allow-forms allow-popups allow-downloads allow-modals` and gains `allow-same-origin` ONLY when the dashboard is explicitly `trusted`. A proxied page is served from Codeman's own origin, so granting it would let the dashboard read the Codeman document and drive the agent-spawning API. ⚠️ In **both** modes, `Authorization` and the `codeman_session` cookie are stripped before the upstream request (`buildUpstreamRequestHeaders`), because a trusted (same-origin) frame makes the browser attach Codeman's own Basic-auth header to every proxied request; forwarding it would hand `CODEMAN_PASSWORD` to the dashboard.
**Two things a sandboxed frame breaks that are invisible to `curl`.** Both were found only by driving a real dashboard in a real browser, and both present identically as the dashboard's own "Failed to fetch" while the page itself renders fine:
1. **Root-absolute URLs built at runtime.** `<base href>` only governs URLs the HTML parser resolves; `fetch('/api/data')` bypasses it and lands on Codeman's root. That is how most dashboards talk to their own backend. The `Referer`-keyed 404 fallback deliberately refuses `/api`, `/ws`, `/q` (widening it there would let a request-supplied header skip auth on Codeman's own API), so the fix is `runtimeUrlShim()`: a small script injected right after `<base>` that rebases root-absolute and same-origin-absolute URLs into the prefix. It removes the whole class inside the iframe instead of trading security for it. ⚠️ It must be injected even when the page ships its OWN `<base>` (an early return there silently breaks exactly the pages that need it most). ⚠️ **The DOM sinks are as load-bearing as `fetch`.** Patching only `fetch`/`XHR`/`WebSocket`/`EventSource` leaves `container.innerHTML = '<img src="/api/hero?slug=x">'` and `img.src = '/api/slide'` untouched, and neither of the other layers can reach those either (`<base>` never applies to root-absolute URLs, and `rewriteHtml()` only ever sees the INITIAL document, never markup built later by page script). The symptom is precise and easy to misdiagnose as an upstream fault: the dashboard's **data** loads while every **image** stays broken. So the shim also wraps `innerHTML`/`outerHTML`/`insertAdjacentHTML`, `setAttribute`/`setAttributeNS`, and the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters, with a `MutationObserver` as a last net for sinks not patched above. Every rewrite routes through the same idempotent `rw()`, which matters because unlike the server-side rewrite this one sees markup that may ALREADY be proxied (a page re-injecting its own `outerHTML` would otherwise double-prefix). The DOM half is pinned in jsdom by `test/webview-proxy.test.ts`; `curl` cannot see any of it.
1. **Root-absolute URLs built at runtime.** `<base href>` only governs URLs the HTML parser resolves; `fetch('/api/data')` bypasses it and lands on Codeman's root. That is how most dashboards talk to their own backend. The `Referer`-keyed 404 fallback is only a rescue (it fires after every real route missed, and only when the browser sends a usable `Referer`), so the fix is `runtimeUrlShim()`: a small script injected right after `<base>` that rebases root-absolute and same-origin-absolute URLs into the prefix. It removes the whole class inside the iframe instead of trading security for it. ⚠️ It must be injected even when the page ships its OWN `<base>` (an early return there silently breaks exactly the pages that need it most). ⚠️ **The DOM sinks are as load-bearing as `fetch`.** Patching only `fetch`/`XHR`/`WebSocket`/`EventSource` leaves `container.innerHTML = '<img src="/api/hero?slug=x">'` and `img.src = '/api/slide'` untouched, and neither of the other layers can reach those either (`<base>` never applies to root-absolute URLs, and `rewriteHtml()` only ever sees the INITIAL document, never markup built later by page script). The symptom is precise and easy to misdiagnose as an upstream fault: the dashboard's **data** loads while every **image** stays broken. So the shim also wraps `innerHTML`/`outerHTML`/`insertAdjacentHTML`, `setAttribute`/`setAttributeNS`, and the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters, with a `MutationObserver` as a last net for sinks not patched above. Every rewrite routes through the same idempotent `rw()`, which matters because unlike the server-side rewrite this one sees markup that may ALREADY be proxied (a page re-injecting its own `outerHTML` would otherwise double-prefix). The DOM half is pinned in jsdom by `test/webview-proxy.test.ts`; `curl` cannot see any of it.
2. **CORS on same-host requests.** An opaque-origin document treats EVERY request as cross-origin, including to the very host it was served from, so its `fetch`/XHR are CORS-checked and its preflights carry `Origin: null`. Static subresources (script/css/img) are NOT CORS-checked, which is why the page renders while its API calls die with an opaque `net::ERR_FAILED`. `buildProxyCorsHeaders()` echoes the origin (omitting `allow-credentials` for `null`, which browsers reject in combination), upstream `access-control-*` headers are dropped (they describe the dashboard's origin, not the frame's), and the proxy answers preflights itself rather than relaying them. ⚠️ `registerSecurityHeaders` answers EVERY `OPTIONS` with a bare 204 before routing, and its CORS block only emits headers for localhost origins, so that short-circuit **must** exempt a valid webview capability or every preflight fails. `curl` cannot reproduce any of this because curl does not enforce CORS.
**Rewrites, each load-bearing** (pure + unit-tested in `src/web/webview-proxy.ts`): drop `x-frame-options` and the CSP `frame-ancestors` directive (the point of the proxy); drop `content-encoding`/`content-length` because undici's `fetch` already decoded the body (forwarding them makes the browser gunzip plaintext); rewrite `Location` for same-origin redirects only, handing CROSS-origin redirects back unchanged so this never becomes an open relay; rebase `Set-Cookie` `Path` onto the prefix and drop `Domain`; inject `<base href>` and rebase root-absolute `src`/`href`/`action`. `resolveUpstreamUrl()` returns null on anything escaping the upstream origin.
+52 -13
View File
@@ -134,18 +134,57 @@ Two details that mattered:
---
## Deliberately NOT done: widening the `/api` referer fallback
## Follow-up (same day): the `/api` referer fallback, done safely
Considered as defense in depth, and skipped. It would have to change **both**
gates or it is useless on a password-protected instance, and the auth gate is the
security-sensitive one: auth runs in `onRequest`, before routing, so it cannot
tell a real Codeman API route from a 404. Dropping the `/api` fence would let a
page holding a capability forge a `Referer` and reach Codeman's **real** API
unauthenticated. Doing it safely means exempting only paths that match no
registered route (via Fastify's `hasRoute`/`findRoute`), which is a separate
change deserving its own review and its own cases in
`test/webview-auth-exemption.test.ts`.
Originally deferred, then implemented on request. Both gates had to move, and the
auth one is the security-sensitive half: auth runs in `onRequest`, before routing,
so it cannot tell a real Codeman API route from a 404, and simply dropping the
`/api` fence would let a page holding a capability forge a `Referer` and reach
Codeman's **real** API unauthenticated.
The remaining gap this leaves is narrow and documented in `docs/web-tabs.md`: a
root-absolute `url(/img.png)` inside a stylesheet injected at runtime. Non-`/api`
ones are already rescued by the existing referer fallback.
What shipped:
- `server.ts`: `tryWebviewRefererFallback` is tried **before** the API-shaped 404.
Reaching that handler already proves no route matched, and the relay declines
unless the `Referer` carries a live capability, so unknown `/api` paths still
get the envelope.
- `middleware/auth.ts`: the `/api/` prefix refusal is replaced by
`matchesRegisteredRoute()`, which refuses the exemption for any path that
resolves to a real route. `/ws/` and `/q/` stay refused by prefix.
Two findings that decided the implementation, both established by probing Fastify
rather than by reading its docs:
- **`hasRoute()` is the wrong tool and would have been a hole.** It matches the
registered PATTERN literally, so `hasRoute({url: '/api/sessions/abc'})` returns
false against a registered `/api/sessions/:id` and would have handed out an
exemption on a live, session-scoped API route. `findRoute()` performs the real
radix-tree lookup and is what the fence uses.
- **`@fastify/static` is mounted at `/`, so it registers a root catch-all that
matches every path.** A match on it means "heading for the 404 handler", not
"real route", and it is distinguishable because a root catch-all is the only
route whose `*` param comes back equal to the whole request path. Without that
carve-out the fence would have refused every referer-form request and broken the
rescue that already worked.
The fence fails closed, and `test/webview-auth-exemption.test.ts` pins both edges
(a concrete URL onto a parametric API route stays 401; the dashboard's own
`/api/...` namespace is served).
### And the CSS gap, which the fallback could NOT close
Testing the fallback against a purpose-built upstream showed the runtime-injected
stylesheet case is unreachable by any relay: a `<style>` element has no URL of its
own, so Chromium sends an **empty `Referer`** with the image request it triggers
and there is nothing to key on. Measured directly:
| sink | Referer the browser sends | fixed by |
| --- | --- | --- |
| `url()` in a proxied `.css` | the stylesheet's proxied URL | the referer relay |
| `url()` in a runtime `<style>` | *empty* | `rwCss()` in the shim |
So the shim also rewrites `url()` inside `<style>` blocks, both when they arrive as
markup and when a `<style>` node is inserted (via the existing MutationObserver).
The only gap left is self-navigation via `location.href = '/x'`, which cannot be
patched because `Location.href` is unforgeable.
+14 -11
View File
@@ -103,11 +103,16 @@ layers cooperate so a dashboard talking to its own backend just works:
proxy serves.
3. A small injected script rebases URLs built at **runtime**, which the first two
cannot see: `fetch('/api/data')` and `new WebSocket('/live')`, but equally
`card.innerHTML = '<img src="/api/hero">'` and `img.src = '/api/slide'`. That
second group is why images are covered too. A dashboard that renders its
thumbnails from script would otherwise show all its data and none of its
pictures, because `<base>` does not apply to root-absolute URLs and the
attribute rewriting only ever saw the initial document.
`card.innerHTML = '<img src="/api/hero">'`, `img.src = '/api/slide'`, and
`url(/img.png)` inside a `<style>` the page injects. That second group is why
images are covered too. A dashboard that renders its thumbnails from script
would otherwise show all its data and none of its pictures, because `<base>`
does not apply to root-absolute URLs and the attribute rewriting only ever saw
the initial document.
4. As a last resort, a request that still lands on Codeman's own root is relayed
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.
On top of that, the proxy answers those requests with CORS headers. That sounds
wrong for same-host requests, but a sandboxed iframe has an *opaque* origin, so the
@@ -117,12 +122,10 @@ then every API call fails, which looks like the dashboard being broken.
## Known limits
- **Exotic loaders.** The three layers above cover normal `fetch`/XHR/WebSocket/
EventSource, normal markup, and the DOM sinks a page uses to build markup at
runtime. Something that constructs requests by an unusual route can still slip
through. Symptom: the page renders but a panel stays empty. The known remaining
gap is a root-absolute `url(/img.png)` inside a stylesheet the page injects at
runtime; one under `/api` has no fallback and will 404.
- **Exotic loaders.** The layers above cover normal `fetch`/XHR/WebSocket/
EventSource, normal markup, the DOM sinks a page uses to build markup at runtime,
and `url()` inside stylesheets. Something that constructs requests by an unusual
route can still slip through. Symptom: the page renders but a panel stays empty.
- **Root-absolute `location` navigation.** A dashboard that navigates itself with
`location.href = '/login'` escapes the prefix, because `Location.href` is
unforgeable and cannot be patched the way the other sinks are. A relative
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.9.0",
"version": "1.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.9.0",
"version": "1.9.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.9.0",
"version": "1.9.1",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+37 -1
View File
@@ -159,12 +159,48 @@ function hasValidWebviewCapability(req: FastifyRequest): boolean {
// capability already implies an authenticated `POST /api/webviews/:id/open`, but
// the exemption should stay no wider than the problem it solves.
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
if (url.startsWith('/api/') || url.startsWith('/ws/') || url.startsWith('/q/')) return false;
if (url.startsWith('/ws/') || url.startsWith('/q/')) return false;
// Anything that resolves to a REAL Codeman route is refused, which is the fence
// that keeps this from being an auth bypass. `/api/` used to be refused by prefix
// instead, but dashboards legitimately serve assets from their own `/api/...`
// namespace (`<img src="/api/hero?slug=x">`), and those requests were the one
// class the 404 relay could never rescue. See matchesRegisteredRoute.
if (matchesRegisteredRoute(req, url)) return false;
const fromReferer = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
}
/**
* Whether `url` resolves to a route Codeman actually registered.
*
* `hasRoute()` is the wrong tool: it matches the registered PATTERN literally, so
* `/api/sessions/abc` reports false against a registered `/api/sessions/:id` and
* would hand out an exemption on a live API route. `findRoute()` performs the real
* radix-tree lookup and fills in `params`, which is what this needs.
*
* The one complication is `@fastify/static`, mounted at `/`, which registers a
* root-level catch-all that matches EVERY path. A match on that means "no real
* route, this is heading for the 404 handler", and it is distinguishable because a
* root catch-all is the only route whose `*` param comes back equal to the entire
* request path. `test/webview-auth-exemption.test.ts` pins both halves of that.
*
* Fails CLOSED: anything unexpected counts as a real route, which merely denies the
* exemption and restores the previous behavior.
*/
function matchesRegisteredRoute(req: FastifyRequest, url: string): boolean {
try {
const found = req.server.findRoute({ method: req.method as 'GET' | 'HEAD', url });
if (!found) return false;
const params = found.params ?? {};
const keys = Object.keys(params);
const isRootCatchAll = keys.length === 1 && keys[0] === '*' && `/${params['*']}` === url;
return !isRootCatchAll;
} catch {
return true;
}
}
/**
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
* Only active when CODEMAN_PASSWORD is set.
+26 -1
View File
@@ -3546,7 +3546,12 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
padding: 4px;
z-index: 1000;
min-width: 190px;
max-width: 300px;
/* Every long row inside (recent-session paths, saved URL names) ellipsizes, so
the cap is a deliberate choice rather than a fit-the-content result: the menu
overlays the terminal and does not need to reach across it. 250 rather than a
rounder 240 because it is the width at which the common `~/<dir>/<repo>` +
timestamp recent-session row still fits whole, which is what that list is for. */
max-width: 250px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
}
.run-mode-menu.active {
@@ -3611,8 +3616,19 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
max-height: 200px;
overflow-y: auto;
}
/* `overflow-y: auto` computes overflow-x to auto as well, which makes this a scroll
container and lets a row size itself to its own content. A long path therefore
scrolled sideways instead of ellipsizing, and before the menu was narrowed it
simply pinned the menu at its max-width. */
.run-mode-history .run-mode-option {
max-width: 100%;
}
.run-mode-option .hist-dir {
flex: 1;
/* A flex item's default min-width is auto, so without this the path refuses to
shrink below its own text and pushes the date out of the menu instead of
ellipsizing. Only visible once the menu is narrow enough to force the choice. */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -12575,7 +12591,16 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
/* The label needs its own element: `text-overflow` on the button does nothing,
because the button is a flex container and a bare text node in one becomes an
anonymous flex item that ellipsis cannot reach. Without this a long dashboard
name widens the whole menu instead of truncating. */
.run-mode-web-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-mode-row-btn {
flex: 0 0 auto;
+1 -1
View File
@@ -312,7 +312,7 @@ Object.assign(CodemanApp.prototype, {
const icon = w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>';
return `<div class="run-mode-row run-mode-row--web">
<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${jsonId})" title="${escapeHtml(w.url)}">
<span class="run-mode-menu-icon">${icon}</span>${name}
<span class="run-mode-menu-icon">${icon}</span><span class="run-mode-web-name">${name}</span>
</button>
<button class="run-mode-row-btn run-mode-webview-edit" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})"
title="Edit URL" aria-label="Edit ${name}">&#x2699;</button>
+12 -5
View File
@@ -855,14 +855,21 @@ export class WebServer extends EventEmitter {
// the envelope hook into a contradictory HTTP 404 {success:true,...}.
this.app.setNotFoundHandler(async (req, reply) => {
const notFound = `Route ${req.method}:${req.url} not found`;
// A web-tab dashboard asking for a root-absolute asset (`fetch('/api/data')`,
// `import('/chunk.js')`, `url(/img.png)` inside a stylesheet) lands here,
// because `<base href>` cannot rewrite a URL built at runtime. Its Referer says
// which dashboard to relay to. Deliberately placed on the 404 path so every
// real Codeman route still wins.
//
// Tried BEFORE the API-shaped 404, because a dashboard's own assets commonly
// live under its `/api/...` namespace and were the one class this could never
// rescue. Reaching this handler at all already proves no Codeman route matched,
// and the relay declines unless the Referer carries a live capability, so
// genuinely unknown `/api` paths still get the envelope below.
if (await tryWebviewRefererFallback(req, reply)) return reply;
if (req.url.startsWith('/api')) {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
}
// A web-tab dashboard asking for a root-absolute asset (`fetch('/api/data')`,
// `import('/chunk.js')`) lands here, because `<base href>` cannot rewrite a URL
// built at runtime. Its Referer says which dashboard to relay to. Deliberately
// placed on the 404 path so every real Codeman route still wins.
if (await tryWebviewRefererFallback(req, reply)) return reply;
return reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
});
+27 -7
View File
@@ -366,11 +366,11 @@ export function buildDownstreamResponseHeaders(
* root, where it 404s. That is not a rare shape: it is how most dashboards talk to
* their own backend, and it presents as the dashboard's own "Failed to fetch".
*
* The `Referer`-keyed 404 fallback catches some of these, but deliberately NOT
* paths under `/api`, `/ws` or `/q` (widening it there would let a request-supplied
* header skip auth on Codeman's own API). Rewriting inside the iframe removes the
* whole class instead of trading security for it: the page never emits a
* root-absolute request in the first place.
* The `Referer`-keyed 404 fallback catches some of these, but it is a rescue rather
* than a fix (it only fires for a request that already missed every Codeman route,
* and only when the browser sends a usable `Referer`). Rewriting inside the iframe
* removes the whole class instead: the page never emits a root-absolute request in
* the first place.
*
* ## Why the DOM sinks are patched too, not just fetch/XHR
*
@@ -455,6 +455,14 @@ function rwAttr(n,v){
return A.indexOf(k)===-1?v:rw(v);
}catch(e){return v;}
}
// CSS built at runtime is the one sink NO relay can rescue: a <style> element has
// no URL of its own, so an opaque-origin document sends an EMPTY Referer with the
// resulting image request, and the 404 fallback has nothing to key on.
function rwCss(s){
try{
return String(s).replace(/url\\(\\s*(['"]?)(\\/(?!\\/)[^'")]*)\\1\\s*\\)/gi,function(m,q,u){return 'url('+q+rw(u)+q+')';});
}catch(e){return s;}
}
// Each value goes through rw() rather than a blind prefix concat, because unlike
// the server-side rewriteHtml() this runs on markup that may ALREADY be proxied
// (a page re-injecting its own outerHTML), and rw() is the idempotent one.
@@ -465,7 +473,8 @@ function rwHtml(s){
.replace(/(\\s(?:src|href|action|poster|formaction|data)\\s*=\\s*")([^"]*)(")/gi,function(m,a,v,q){return a+rw(v)+q;})
.replace(/(\\s(?:src|href|action|poster|formaction|data)\\s*=\\s*')([^']*)(')/gi,function(m,a,v,q){return a+rw(v)+q;})
.replace(/(\\ssrcset\\s*=\\s*")([^"]*)(")/gi,function(m,a,v,q){return a+rwSet(v)+q;})
.replace(/(\\ssrcset\\s*=\\s*')([^']*)(')/gi,function(m,a,v,q){return a+rwSet(v)+q;});
.replace(/(\\ssrcset\\s*=\\s*')([^']*)(')/gi,function(m,a,v,q){return a+rwSet(v)+q;})
.replace(/(<style\\b[^>]*>)([^]*?)(<\\/style>)/gi,function(m,a,b,c){return a+rwCss(b)+c;});
}catch(e){return s;}
}
// Marked with __cmrw so a double injection (a page that re-runs the shim) cannot
@@ -530,12 +539,23 @@ try{
}
}catch(e){}
};
var fixStyle=function(el){
try{
if(!el||el.tagName!=='STYLE')return;
var t=el.textContent;
if(!t||t.indexOf('url(')===-1)return;
var n=rwCss(t);
if(n!==t)el.textContent=n;
}catch(e){}
};
var scan=function(node){
try{
fix(node);
fix(node);fixStyle(node);
if(node&&node.querySelectorAll){
var l=node.querySelectorAll('[src],[href],[action],[poster],[data],[srcset],[formaction]');
for(var i=0;i<l.length;i++)fix(l[i]);
var st=node.querySelectorAll('style');
for(var j=0;j<st.length;j++)fixStyle(st[j]);
}
}catch(e){}
};
+61 -3
View File
@@ -42,9 +42,16 @@ beforeEach(async () => {
// Stand-ins for the real surfaces, so a reachable route means auth let it through.
app.all('/webview/:cap/*', async () => ({ proxied: true }));
app.all('/api/sessions', async () => ({ sensitive: true }));
// Parametric on purpose: the exemption's fence has to resolve a CONCRETE url
// against it, which is precisely what `hasRoute()` cannot do.
app.all('/api/sessions/:id', async () => ({ sensitive: true }));
app.all('/q/:token', async () => ({ qr: true }));
app.get('/', async () => 'app shell');
app.get('/static/app.js', async () => 'asset');
app.get('/webviewfoo/bar', async () => 'lookalike');
// Stand-in for @fastify/static mounted at '/', which is what actually serves
// /static/app.js in production. It matches EVERY path, so the fence must treat a
// root catch-all as "no real route" or the Referer form could never apply at all.
app.get('/*', async () => 'static asset');
await app.ready();
});
@@ -109,6 +116,21 @@ describe('the exemption applies to a live capability', () => {
});
expect(res.statusCode).toBe(200);
});
it("covers the dashboard's OWN /api namespace, which no Codeman route claims", async () => {
// A dashboard serving `<img src="/api/hero?slug=x">` from page script is the
// case this exists for: the URL is root-absolute, so it lands on Codeman, and
// nothing here matches a real route. Refusing it by `/api` prefix (as this once
// did) left dashboard images permanently broken with no way to rescue them.
for (const url of ['/api/hero?slug=x', '/api/slide?owner=o&n=01', '/api/preview']) {
const res = await app.inject({
method: 'GET',
url,
headers: { referer: `http://localhost/webview/${capability}/panel` },
});
expect(res.statusCode, url).toBe(200);
}
});
});
describe('the exemption does NOT widen anywhere else', () => {
@@ -132,8 +154,8 @@ describe('the exemption does NOT widen anywhere else', () => {
expect((await app.inject({ method: 'GET', url: '/webviewfoo/bar' })).statusCode).toBe(401);
});
it('NEVER exempts the Codeman API, even with a valid capability in the Referer', async () => {
// This is the hole the Referer form would open if it were not path-fenced.
it('NEVER exempts a real Codeman API route, even with a valid capability in the Referer', async () => {
// This is the hole the Referer form would open if it were not fenced.
const res = await app.inject({
method: 'GET',
url: '/api/sessions',
@@ -142,6 +164,42 @@ describe('the exemption does NOT widen anywhere else', () => {
expect(res.statusCode).toBe(401);
});
it('NEVER exempts a PARAMETRIC API route matched by a concrete url', async () => {
// The fence has to route `/api/sessions/abc` onto `/api/sessions/:id`. A literal
// pattern check (`hasRoute`) reports no match here and would hand out an
// exemption on a live, session-scoped API route.
for (const url of ['/api/sessions/abc', '/api/sessions/abc?x=1']) {
const res = await app.inject({
method: 'GET',
url,
headers: { referer: `http://localhost/webview/${capability}/panel` },
});
expect(res.statusCode, url).toBe(401);
}
});
it('still refuses the websocket namespace outright', async () => {
// `/q/` is deliberately absent here: QR login is PUBLIC by its own bypass
// (an unauthenticated device is the entire point), so it can never demonstrate
// anything about this exemption. The `/q/` guard alongside it is belt-and-braces.
const res = await app.inject({
method: 'GET',
url: '/ws/anything',
headers: { referer: `http://localhost/webview/${capability}/panel` },
});
expect(res.statusCode).toBe(401);
});
it('does not exempt an unrouted /api path without a live capability in the Referer', async () => {
expect((await app.inject({ method: 'GET', url: '/api/hero?slug=x' })).statusCode).toBe(401);
const stale = await app.inject({
method: 'GET',
url: '/api/hero?slug=x',
headers: { referer: `http://localhost/webview/${'Z'.repeat(32)}/panel` },
});
expect(stale.statusCode).toBe(401);
});
it('does not let the Referer form carry a WRITE', async () => {
const res = await app.inject({
method: 'POST',
+35
View File
@@ -589,6 +589,41 @@ describe('runtimeUrlShim DOM sinks', () => {
expect(imgSrc(dom)).toBe('');
});
/**
* CSS is the sink no relay can rescue: a <style> element has no URL of its own,
* so an opaque-origin document sends an EMPTY Referer with the image request it
* triggers, and the 404 fallback has nothing to key on. Verified in Chromium.
*/
it('rewrites root-absolute url() inside a <style> injected as markup', () => {
const dom = newDom();
dom.window.document.getElementById('box')!.innerHTML = '<style>#hero{background-image:url(/api/hero.png)}</style>';
expect(dom.window.document.querySelector('#box style')!.textContent).toBe(
`#hero{background-image:url(${PREFIX}api/hero.png)}`
);
});
it('rewrites url() in a <style> built with textContent, via the observer', async () => {
const dom = newDom();
const { document } = dom.window;
const style = document.createElement('style');
style.textContent = "#late{background-image:url('/api/late.png')}";
document.head.appendChild(style);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(style.textContent).toBe(`#late{background-image:url('${PREFIX}api/late.png')}`);
});
it('leaves relative, cross-origin and data: url() alone', () => {
const dom = newDom();
const css = [
'a{background:url(img/rel.png)}',
'b{background:url(https://cdn.example/z.png)}',
'c{background:url(data:image/gif;base64,AAAA)}',
`d{background:url(${PREFIX}api/done.png)}`,
].join('');
dom.window.document.getElementById('box')!.innerHTML = `<style>${css}</style>`;
expect(dom.window.document.querySelector('#box style')!.textContent).toBe(css);
});
it('is idempotent when a value passes through two layers', () => {
const dom = newDom();
const img = dom.window.document.createElement('img');