fix(web): bound retained app documents by size; correct the cookie claim
Copilot review round 5 on #2100. - The store bounded entry COUNT only, and the route admits 8Mi code units per document with a one-hour TTL — ~256Mi retainable on documents a server under test chooses the size of. Added a 64Mi retained-size budget evicted oldest-first alongside the count bound, with a running total kept in step with the map through a single `drop` seam so it cannot drift. - The isolation claim was wrong about cookies. A separate port makes a separate ORIGIN, so DOM access, localStorage, sessionStorage and IndexedDB are isolated — but cookies are keyed by host and path, ignoring port, so an app served here shares the 127.0.0.1 jar with the Inspector and every other loopback service. Not a hole in the Inspector's own auth (the API token is a header plus a window global and sessionStorage, none of them cookies) and not closable without a distinct host this listener cannot mint, so it is stated plainly in both the module header and clients/web/README.md rather than left as a claim that is false for one of the three surfaces it names. - Removed a shell-escaping artifact I left in the migration guide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
73bf0a2522
commit
6011d4de03
4 changed files with 110 additions and 12 deletions
|
|
@ -182,6 +182,23 @@ is *for* — a real, allowlistable origin — without minting a port or a DNS na
|
||||||
per app, at the cost of not being a per-app isolation boundary: two such apps
|
per app, at the cost of not being a per-app isolation boundary: two such apps
|
||||||
share `localStorage`, `sessionStorage`, and cookies for that origin.
|
share `localStorage`, `sessionStorage`, and cookies for that origin.
|
||||||
|
|
||||||
|
**And the separate port does not isolate cookies from the rest of loopback.**
|
||||||
|
A distinct port makes a distinct *origin*, so the origin-scoped surfaces —
|
||||||
|
DOM/scripting access, `localStorage`, `sessionStorage`, IndexedDB — are isolated
|
||||||
|
from the sandbox proxy and from the Inspector page. **Cookies are not
|
||||||
|
origin-scoped**: they are keyed by host and path, ignoring port. An app served
|
||||||
|
here therefore shares the `127.0.0.1` cookie jar with the Inspector on `6274`
|
||||||
|
and with anything else on loopback — it can read any non-`HttpOnly` cookie set
|
||||||
|
for that host, and set `Path=/` cookies those services will receive.
|
||||||
|
|
||||||
|
This is not a hole in the Inspector's own auth: the API token travels in the
|
||||||
|
`x-mcp-remote-auth` header and lives in a `window` global and `sessionStorage`,
|
||||||
|
neither of which another origin can read. Closing the cookie gap properly needs
|
||||||
|
a distinct *host*, which this listener cannot mint (the Inspector owns no DNS,
|
||||||
|
and a second loopback address is not portable) — so it is stated rather than
|
||||||
|
papered over. Don't run the Inspector beside a loopback service whose session
|
||||||
|
cookie you would not hand to an app under test.
|
||||||
|
|
||||||
**Every failure falls back rather than blanking the app.** No app-origin
|
**Every failure falls back rather than blanking the app.** No app-origin
|
||||||
listener, a port that never bound, an older backend with no
|
listener, a port that never bound, an older backend with no
|
||||||
`POST /api/app-document` route, a network error — each renders the app the
|
`POST /api/app-document` route, a network error — each renders the app the
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,28 @@
|
||||||
* the spec field is *for* (a real, allowlistable origin) without minting a port
|
* the spec field is *for* (a real, allowlistable origin) without minting a port
|
||||||
* — or a DNS name — per app. The consequence is that this origin is **not** a
|
* — or a DNS name — per app. The consequence is that this origin is **not** a
|
||||||
* per-app isolation boundary: two apps served here share `localStorage`,
|
* per-app isolation boundary: two apps served here share `localStorage`,
|
||||||
* `sessionStorage`, and cookies for it. They remain isolated from the sandbox
|
* `sessionStorage`, and cookies for it.
|
||||||
* proxy and from the Inspector itself, which are different origins.
|
*
|
||||||
|
* ## What the separate port does and does not isolate
|
||||||
|
*
|
||||||
|
* A distinct port makes a distinct **origin**, so the usual origin-scoped
|
||||||
|
* surfaces are isolated from the sandbox proxy and from the Inspector page:
|
||||||
|
* DOM and scripting access, `localStorage`, `sessionStorage`, IndexedDB.
|
||||||
|
*
|
||||||
|
* **Cookies are the exception, and they are not isolated.** Cookies are scoped
|
||||||
|
* by host (and path), not by port — so an app served here shares the
|
||||||
|
* `127.0.0.1` cookie jar with the Inspector on `6274` and with every other
|
||||||
|
* service on loopback. It can read any non-`HttpOnly` cookie set for that host
|
||||||
|
* and set `Path=/` cookies those services will receive.
|
||||||
|
*
|
||||||
|
* That is a real caveat, but it is not a hole in the Inspector's own auth: the
|
||||||
|
* API token travels in the `x-mcp-remote-auth` header and is held in a
|
||||||
|
* `window` global and `sessionStorage`, both of which a different origin
|
||||||
|
* cannot read. Closing the cookie gap properly needs a distinct *host*, which
|
||||||
|
* this listener cannot mint — the Inspector owns no DNS, and a second loopback
|
||||||
|
* address is not portable. So it is documented rather than papered over; do
|
||||||
|
* not run the Inspector beside a loopback service whose session cookie you
|
||||||
|
* would not hand to an app under test.
|
||||||
*
|
*
|
||||||
* ## Why granting `allow-same-origin` here is not a regression
|
* ## Why granting `allow-same-origin` here is not a regression
|
||||||
*
|
*
|
||||||
|
|
@ -77,6 +97,21 @@ export const DEFAULT_APP_ORIGIN_PORT = 6278;
|
||||||
/** Documents kept at once. Oldest is evicted past this; see {@link publish}. */
|
/** Documents kept at once. Oldest is evicted past this; see {@link publish}. */
|
||||||
const MAX_DOCUMENTS = 32;
|
const MAX_DOCUMENTS = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total retained document size, in UTF-16 code units, across every live entry.
|
||||||
|
*
|
||||||
|
* {@link MAX_DOCUMENTS} alone bounds the *count*, and the route admits up to
|
||||||
|
* 8Mi code units per document — so the count bound permits ~256Mi retained for
|
||||||
|
* a full hour, enough to OOM a memory-limited Inspector on documents a server
|
||||||
|
* chose the size of. Both bounds apply; whichever binds first evicts.
|
||||||
|
*
|
||||||
|
* 64Mi is roughly a full complement of ordinary widgets (a real app document is
|
||||||
|
* tens to hundreds of KiB) or a couple of pathological ones. An app large
|
||||||
|
* enough to evict its predecessors still renders — the frame fetches its `src`
|
||||||
|
* immediately, long before anything could push it out.
|
||||||
|
*/
|
||||||
|
const MAX_RETAINED_CHARS = 64 * 1024 * 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long a published document stays fetchable. An iframe fetches its `src`
|
* How long a published document stays fetchable. An iframe fetches its `src`
|
||||||
* once, so this only has to outlive the gap between publishing and the frame
|
* once, so this only has to outlive the gap between publishing and the frame
|
||||||
|
|
@ -192,6 +227,8 @@ export function parseDocumentId(url: string | undefined): string | null {
|
||||||
|
|
||||||
interface StoredDocument extends AppDocument {
|
interface StoredDocument extends AppDocument {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
/** Retained size in UTF-16 code units, against {@link MAX_RETAINED_CHARS}. */
|
||||||
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAppOriginController(
|
export function createAppOriginController(
|
||||||
|
|
@ -206,13 +243,25 @@ export function createAppOriginController(
|
||||||
|
|
||||||
// Insertion-ordered, so the first key is the oldest entry.
|
// Insertion-ordered, so the first key is the oldest entry.
|
||||||
const documents = new Map<string, StoredDocument>();
|
const documents = new Map<string, StoredDocument>();
|
||||||
|
// Running sum of every live entry's `size`, kept in step with the map on
|
||||||
|
// every insert and every delete. Tracked rather than recomputed: eviction
|
||||||
|
// runs per publish and summing the map there would be O(n) on a hot path.
|
||||||
|
let retainedChars = 0;
|
||||||
|
|
||||||
|
/** The single removal seam, so the running total cannot drift from the map. */
|
||||||
|
function drop(id: string): void {
|
||||||
|
const doc = documents.get(id);
|
||||||
|
if (!doc) return;
|
||||||
|
retainedChars -= doc.size;
|
||||||
|
documents.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
const FRAME_ANCESTORS = frameAncestorsDirective(embedderOrigins);
|
const FRAME_ANCESTORS = frameAncestorsDirective(embedderOrigins);
|
||||||
|
|
||||||
/** Drop everything past its TTL. Cheap: the map is bounded by MAX_DOCUMENTS. */
|
/** Drop everything past its TTL. Cheap: the map is bounded by MAX_DOCUMENTS. */
|
||||||
function evictExpired(now: number): void {
|
function evictExpired(now: number): void {
|
||||||
for (const [id, doc] of documents) {
|
for (const [id, doc] of documents) {
|
||||||
if (doc.expiresAt <= now) documents.delete(id);
|
if (doc.expiresAt <= now) drop(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,7 +284,7 @@ export function createAppOriginController(
|
||||||
const id = req.method === "GET" ? parseDocumentId(req.url) : null;
|
const id = req.method === "GET" ? parseDocumentId(req.url) : null;
|
||||||
const doc = id ? documents.get(id) : undefined;
|
const doc = id ? documents.get(id) : undefined;
|
||||||
if (!doc || doc.expiresAt <= Date.now()) {
|
if (!doc || doc.expiresAt <= Date.now()) {
|
||||||
if (id) documents.delete(id);
|
if (id) drop(id);
|
||||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||||
res.end("Not Found");
|
res.end("Not Found");
|
||||||
return;
|
return;
|
||||||
|
|
@ -293,6 +342,7 @@ export function createAppOriginController(
|
||||||
server = null;
|
server = null;
|
||||||
origin = null;
|
origin = null;
|
||||||
documents.clear();
|
documents.clear();
|
||||||
|
retainedChars = 0;
|
||||||
settle({ port: 0, url: "" });
|
settle({ port: 0, url: "" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -322,6 +372,7 @@ export function createAppOriginController(
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
documents.clear();
|
documents.clear();
|
||||||
|
retainedChars = 0;
|
||||||
if (!server) return;
|
if (!server) return;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
server!.close(() => {
|
server!.close(() => {
|
||||||
|
|
@ -340,17 +391,23 @@ export function createAppOriginController(
|
||||||
if (!origin) return null;
|
if (!origin) return null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
evictExpired(now);
|
evictExpired(now);
|
||||||
// Bound the map even if nothing has expired: drop the oldest entry. An
|
const size = doc.html.length + (doc.csp?.length ?? 0);
|
||||||
// evicted document's frame has long since loaded, and the alternative is
|
// Bound the store even if nothing has expired: drop oldest-first until
|
||||||
// an unbounded store fed by every app the user opens in a session.
|
// BOTH the count and the retained-size budget have room. An evicted
|
||||||
while (documents.size >= MAX_DOCUMENTS) {
|
// document's frame has long since loaded, and the alternative is a store
|
||||||
|
// fed — in count and in bytes — by whatever the servers under test send.
|
||||||
|
while (
|
||||||
|
documents.size >= MAX_DOCUMENTS ||
|
||||||
|
(documents.size > 0 && retainedChars + size > MAX_RETAINED_CHARS)
|
||||||
|
) {
|
||||||
const oldest = documents.keys().next();
|
const oldest = documents.keys().next();
|
||||||
/* v8 ignore next -- `size >= MAX_DOCUMENTS` guarantees a first key. */
|
/* v8 ignore next -- both loop conditions require a non-empty map. */
|
||||||
if (oldest.done) break;
|
if (oldest.done) break;
|
||||||
documents.delete(oldest.value);
|
drop(oldest.value);
|
||||||
}
|
}
|
||||||
const id = randomBytes(16).toString("hex");
|
const id = randomBytes(16).toString("hex");
|
||||||
documents.set(id, { ...doc, expiresAt: now + DOCUMENT_TTL_MS });
|
documents.set(id, { ...doc, expiresAt: now + DOCUMENT_TTL_MS, size });
|
||||||
|
retainedChars += size;
|
||||||
return { url: `${origin}${DOC_PATH_PREFIX}${id}` };
|
return { url: `${origin}${DOC_PATH_PREFIX}${id}` };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,30 @@ describe("createAppOriginController", () => {
|
||||||
expect((await fetch(first.url)).status).toBe(404);
|
expect((await fetch(first.url)).status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("evicts against a retained-size budget, not just the entry count", async () => {
|
||||||
|
// The count bound alone permits MAX_DOCUMENTS * 8Mi retained for an hour,
|
||||||
|
// on documents a server under test chooses the size of. Publish a few
|
||||||
|
// large ones — well under the 32-entry cap — and the earliest must stop
|
||||||
|
// being served.
|
||||||
|
const c = createAppOriginController({ port: 0, host: "127.0.0.1" });
|
||||||
|
controller = c;
|
||||||
|
const { url } = await c.start();
|
||||||
|
// 24Mi each: three fit under no count bound, but the third pushes total
|
||||||
|
// retained past the 64Mi budget and the first has to go.
|
||||||
|
const big = "x".repeat(24 * 1024 * 1024);
|
||||||
|
const first = c.publish({ html: big });
|
||||||
|
const second = c.publish({ html: big });
|
||||||
|
expect(first).not.toBeNull();
|
||||||
|
expect(second).not.toBeNull();
|
||||||
|
const third = c.publish({ html: big });
|
||||||
|
expect(third).not.toBeNull();
|
||||||
|
|
||||||
|
expect((await fetch(first!.url)).status).toBe(404);
|
||||||
|
// The newest is always served — its frame is about to fetch it.
|
||||||
|
expect((await fetch(third!.url)).status).toBe(200);
|
||||||
|
expect(url).toBe(c.getOrigin());
|
||||||
|
});
|
||||||
|
|
||||||
it("404s a document past its TTL", async () => {
|
it("404s a document past its TTL", async () => {
|
||||||
// Fake `Date` ONLY — the TTL is the sole thing that reads it, and faking
|
// Fake `Date` ONLY — the TTL is the sole thing that reads it, and faking
|
||||||
// the timer queue too would deadlock the real HTTP round-trip below.
|
// the timer queue too would deadlock the real HTTP round-trip below.
|
||||||
|
|
|
||||||
|
|
@ -368,7 +368,7 @@ Notes:
|
||||||
|
|
||||||
**"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second port (`6275` by default). Publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network).
|
**"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second port (`6275` by default). Publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network).
|
||||||
|
|
||||||
**"One particular App'"'"'s frame is blank, while other Apps render fine."** That App declares `_meta.ui.domain`, so it is served from the **third** port (`MCP_APP_ORIGIN_PORT`, `6278` by default) rather than from the sandbox — publish or forward that port too. Expect no console warning: the backend published the document successfully, so from its side nothing failed, and the browser'"'"'s inability to load a cross-origin URL is not observable from the page. The neighbouring symptom is different — an App that *renders* and has its requests rejected by its own backend is running on the opaque-origin fallback, which means publication itself failed (no listener, or a port that never bound) and the console **does** carry a warning naming `_meta.ui.domain`. See [MCP App dedicated origins](../clients/web/README.md#mcp-app-dedicated-origins-metauidomain).
|
**"One particular App's frame is blank, while other Apps render fine."** That App declares `_meta.ui.domain`, so it is served from the **third** port (`MCP_APP_ORIGIN_PORT`, `6278` by default) rather than from the sandbox — publish or forward that port too. Expect no console warning: the backend published the document successfully, so from its side nothing failed, and the browser's inability to load a cross-origin URL is not observable from the page. The neighbouring symptom is different — an App that *renders* and has its requests rejected by its own backend is running on the opaque-origin fallback, which means publication itself failed (no listener, or a port that never bound) and the console **does** carry a warning naming `_meta.ui.domain`. See [MCP App dedicated origins](../clients/web/README.md#mcp-app-dedicated-origins-metauidomain).
|
||||||
|
|
||||||
**"The page never loads inside a VS Code dev container."** Fixed in the release carrying #1951 — upgrade; on an older version, work around it with `HOST=127.0.0.1`. Before that fix the default `HOST=localhost` bound IPv6 loopback **only** on Linux, so anything connecting over IPv4 `127.0.0.1` was refused — which is what a dev container's port forwarder does. The terminal printed a normal banner while the browser spun forever; the failure is a **hang** rather than a connection error because the forwarder accepts on the host side before failing to connect inward. The same fix covers an `ssh -L 6274:127.0.0.1:6274` tunnel and a container healthcheck. It does **not** describe the official Docker image (it binds `0.0.0.0`, so it was never affected) or a plain `docker run -p` against a loopback bind, which is unreachable across the network namespace for an unrelated reason.
|
**"The page never loads inside a VS Code dev container."** Fixed in the release carrying #1951 — upgrade; on an older version, work around it with `HOST=127.0.0.1`. Before that fix the default `HOST=localhost` bound IPv6 loopback **only** on Linux, so anything connecting over IPv4 `127.0.0.1` was refused — which is what a dev container's port forwarder does. The terminal printed a normal banner while the browser spun forever; the failure is a **hang** rather than a connection error because the forwarder accepts on the host side before failing to connect inward. The same fix covers an `ssh -L 6274:127.0.0.1:6274` tunnel and a container healthcheck. It does **not** describe the official Docker image (it binds `0.0.0.0`, so it was never affected) or a plain `docker run -p` against a loopback bind, which is unreachable across the network namespace for an unrelated reason.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue