mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
chore: version packages
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,19 @@
|
|||||||
# aicodeman
|
# 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
|
## 1.9.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -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.
|
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
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
**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.
|
**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:
|
**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.
|
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.
|
**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
@@ -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**
|
Originally deferred, then implemented on request. Both gates had to move, and the
|
||||||
gates or it is useless on a password-protected instance, and the auth gate is the
|
auth one is the security-sensitive half: auth runs in `onRequest`, before routing,
|
||||||
security-sensitive one: auth runs in `onRequest`, before routing, so it cannot
|
so it cannot tell a real Codeman API route from a 404, and simply dropping the
|
||||||
tell a real Codeman API route from a 404. Dropping the `/api` fence would let a
|
`/api` fence would let a page holding a capability forge a `Referer` and reach
|
||||||
page holding a capability forge a `Referer` and reach Codeman's **real** API
|
Codeman's **real** API unauthenticated.
|
||||||
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`.
|
|
||||||
|
|
||||||
The remaining gap this leaves is narrow and documented in `docs/web-tabs.md`: a
|
What shipped:
|
||||||
root-absolute `url(/img.png)` inside a stylesheet injected at runtime. Non-`/api`
|
|
||||||
ones are already rescued by the existing referer fallback.
|
- `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
@@ -103,11 +103,16 @@ layers cooperate so a dashboard talking to its own backend just works:
|
|||||||
proxy serves.
|
proxy serves.
|
||||||
3. A small injected script rebases URLs built at **runtime**, which the first two
|
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
|
cannot see: `fetch('/api/data')` and `new WebSocket('/live')`, but equally
|
||||||
`card.innerHTML = '<img src="/api/hero">'` and `img.src = '/api/slide'`. That
|
`card.innerHTML = '<img src="/api/hero">'`, `img.src = '/api/slide'`, and
|
||||||
second group is why images are covered too. A dashboard that renders its
|
`url(/img.png)` inside a `<style>` the page injects. That second group is why
|
||||||
thumbnails from script would otherwise show all its data and none of its
|
images are covered too. A dashboard that renders its thumbnails from script
|
||||||
pictures, because `<base>` does not apply to root-absolute URLs and the
|
would otherwise show all its data and none of its pictures, because `<base>`
|
||||||
attribute rewriting only ever saw the initial document.
|
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
|
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
|
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
|
## Known limits
|
||||||
|
|
||||||
- **Exotic loaders.** The three layers above cover normal `fetch`/XHR/WebSocket/
|
- **Exotic loaders.** The layers above cover normal `fetch`/XHR/WebSocket/
|
||||||
EventSource, normal markup, and the DOM sinks a page uses to build markup at
|
EventSource, normal markup, the DOM sinks a page uses to build markup at runtime,
|
||||||
runtime. Something that constructs requests by an unusual route can still slip
|
and `url()` inside stylesheets. Something that constructs requests by an unusual
|
||||||
through. Symptom: the page renders but a panel stays empty. The known remaining
|
route can still slip through. Symptom: the page renders but a panel stays empty.
|
||||||
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.
|
|
||||||
- **Root-absolute `location` navigation.** A dashboard that navigates itself with
|
- **Root-absolute `location` navigation.** A dashboard that navigates itself with
|
||||||
`location.href = '/login'` escapes the prefix, because `Location.href` is
|
`location.href = '/login'` escapes the prefix, because `Location.href` is
|
||||||
unforgeable and cannot be patched the way the other sinks are. A relative
|
unforgeable and cannot be patched the way the other sinks are. A relative
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.9.0",
|
"version": "1.9.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.9.0",
|
"version": "1.9.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"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",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -159,12 +159,48 @@ function hasValidWebviewCapability(req: FastifyRequest): boolean {
|
|||||||
// capability already implies an authenticated `POST /api/webviews/:id/open`, but
|
// capability already implies an authenticated `POST /api/webviews/:id/open`, but
|
||||||
// the exemption should stay no wider than the problem it solves.
|
// the exemption should stay no wider than the problem it solves.
|
||||||
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
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);
|
const fromReferer = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
|
||||||
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== 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.
|
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
|
||||||
* Only active when CODEMAN_PASSWORD is set.
|
* Only active when CODEMAN_PASSWORD is set.
|
||||||
|
|||||||
@@ -3546,7 +3546,12 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
padding: 4px;
|
padding: 4px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
min-width: 190px;
|
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);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
.run-mode-menu.active {
|
.run-mode-menu.active {
|
||||||
@@ -3611,8 +3616,19 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
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 {
|
.run-mode-option .hist-dir {
|
||||||
flex: 1;
|
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;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -12575,7 +12591,16 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
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;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.run-mode-row-btn {
|
.run-mode-row-btn {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const icon = w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>';
|
const icon = w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>';
|
||||||
return `<div class="run-mode-row run-mode-row--web">
|
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)}">
|
<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>
|
||||||
<button class="run-mode-row-btn run-mode-webview-edit" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})"
|
<button class="run-mode-row-btn run-mode-webview-edit" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})"
|
||||||
title="Edit URL" aria-label="Edit ${name}">⚙</button>
|
title="Edit URL" aria-label="Edit ${name}">⚙</button>
|
||||||
|
|||||||
+12
-5
@@ -855,14 +855,21 @@ export class WebServer extends EventEmitter {
|
|||||||
// the envelope hook into a contradictory HTTP 404 {success:true,...}.
|
// the envelope hook into a contradictory HTTP 404 {success:true,...}.
|
||||||
this.app.setNotFoundHandler(async (req, reply) => {
|
this.app.setNotFoundHandler(async (req, reply) => {
|
||||||
const notFound = `Route ${req.method}:${req.url} not found`;
|
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')) {
|
if (req.url.startsWith('/api')) {
|
||||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
|
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 });
|
return reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -366,11 +366,11 @@ export function buildDownstreamResponseHeaders(
|
|||||||
* root, where it 404s. That is not a rare shape: it is how most dashboards talk to
|
* 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".
|
* 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
|
* The `Referer`-keyed 404 fallback catches some of these, but it is a rescue rather
|
||||||
* paths under `/api`, `/ws` or `/q` (widening it there would let a request-supplied
|
* than a fix (it only fires for a request that already missed every Codeman route,
|
||||||
* header skip auth on Codeman's own API). Rewriting inside the iframe removes the
|
* and only when the browser sends a usable `Referer`). Rewriting inside the iframe
|
||||||
* whole class instead of trading security for it: the page never emits a
|
* removes the whole class instead: the page never emits a root-absolute request in
|
||||||
* root-absolute request in the first place.
|
* the first place.
|
||||||
*
|
*
|
||||||
* ## Why the DOM sinks are patched too, not just fetch/XHR
|
* ## 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);
|
return A.indexOf(k)===-1?v:rw(v);
|
||||||
}catch(e){return 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
|
// 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
|
// 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.
|
// (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(/(\\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(/(\\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;}
|
}catch(e){return s;}
|
||||||
}
|
}
|
||||||
// Marked with __cmrw so a double injection (a page that re-runs the shim) cannot
|
// Marked with __cmrw so a double injection (a page that re-runs the shim) cannot
|
||||||
@@ -530,12 +539,23 @@ try{
|
|||||||
}
|
}
|
||||||
}catch(e){}
|
}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){
|
var scan=function(node){
|
||||||
try{
|
try{
|
||||||
fix(node);
|
fix(node);fixStyle(node);
|
||||||
if(node&&node.querySelectorAll){
|
if(node&&node.querySelectorAll){
|
||||||
var l=node.querySelectorAll('[src],[href],[action],[poster],[data],[srcset],[formaction]');
|
var l=node.querySelectorAll('[src],[href],[action],[poster],[data],[srcset],[formaction]');
|
||||||
for(var i=0;i<l.length;i++)fix(l[i]);
|
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){}
|
}catch(e){}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,9 +42,16 @@ beforeEach(async () => {
|
|||||||
// Stand-ins for the real surfaces, so a reachable route means auth let it through.
|
// Stand-ins for the real surfaces, so a reachable route means auth let it through.
|
||||||
app.all('/webview/:cap/*', async () => ({ proxied: true }));
|
app.all('/webview/:cap/*', async () => ({ proxied: true }));
|
||||||
app.all('/api/sessions', async () => ({ sensitive: 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('/', async () => 'app shell');
|
||||||
app.get('/static/app.js', async () => 'asset');
|
|
||||||
app.get('/webviewfoo/bar', async () => 'lookalike');
|
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();
|
await app.ready();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,6 +116,21 @@ describe('the exemption applies to a live capability', () => {
|
|||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(200);
|
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', () => {
|
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);
|
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 () => {
|
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 path-fenced.
|
// This is the hole the Referer form would open if it were not fenced.
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/api/sessions',
|
url: '/api/sessions',
|
||||||
@@ -142,6 +164,42 @@ describe('the exemption does NOT widen anywhere else', () => {
|
|||||||
expect(res.statusCode).toBe(401);
|
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 () => {
|
it('does not let the Referer form carry a WRITE', async () => {
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -589,6 +589,41 @@ describe('runtimeUrlShim DOM sinks', () => {
|
|||||||
expect(imgSrc(dom)).toBe('');
|
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', () => {
|
it('is idempotent when a value passes through two layers', () => {
|
||||||
const dom = newDom();
|
const dom = newDom();
|
||||||
const img = dom.window.document.createElement('img');
|
const img = dom.window.document.createElement('img');
|
||||||
|
|||||||
Reference in New Issue
Block a user