diff --git a/kloe.schema.json b/kloe.schema.json --- a/kloe.schema.json +++ b/kloe.schema.json @@ -16,12 +16,18 @@ }, "dbPath": { "type": "string", "default": "data/kloe.db" + }, + "artifactOrigin": { + "type": "string", + "pattern": "^(https?:\\/\\/[^/\\s]+)?$", + "default": "" } }, "required": [], "default": { "port": 3000, - "dbPath": "data/kloe.db" + "dbPath": "data/kloe.db", + "artifactOrigin": "" } }, "blobs": { diff --git a/server.ts b/server.ts --- a/server.ts +++ b/server.ts @@ -13,6 +13,7 @@ import { sweepInputDeltas, sweepOrphanBlobs } from "./src/gc"; import { apiRoutes, evictIdleActors, getActor } from "./src/http"; import { initInference } from "./src/inference"; import { handleLardCallback, handleLardConnect } from "./src/lard"; +import { sandboxRoutes } from "./src/sandbox"; import { getConfig } from "./src/settings"; import { shareRoutes } from "./src/share"; import { Store } from "./src/store"; @@ -228,6 +229,10 @@ "/lard/callback": (req: Request) => handleLardCallback(req, store), // Public read paths for published documents. Registered here rather than // inside apiRoutes because everything there is wrapped by the auth gate. ...shareRoutes({ store, blobs }), + // An HTML artifact, served as a live page from an origin that owns + // nothing — the only place one can be given a camera without being given + // the app. Inert until server.artifactOrigin names a host (see sandbox.ts). + ...sandboxRoutes({ store, blobs }), ...staticRoutes, ...vendorRoutes, ...assetRoutes, diff --git a/src/client/app.js b/src/client/app.js --- a/src/client/app.js +++ b/src/client/app.js @@ -1753,6 +1753,9 @@ // renderer the conversation uses, so code and math get the same treatment), // with copy and download. One pane, reused — opening a second artifact // replaces the first rather than stacking. var paneDoc = null; + // The origin an HTML artifact may be RUN on, from /api/me. Empty (the + // default) keeps every page inert in a sandbox that owns nothing. + var artifactOrigin = ""; // HTML documents show their rendered self by default and their source on // request. Per-open rather than sticky: the point of the pane is the document. var paneSource = false; @@ -1951,9 +1954,29 @@ */ function renderHtmlDoc(body, text) { var frame = document.createElement("iframe"); frame.className = "htmlframe"; - frame.setAttribute("sandbox", "allow-scripts"); frame.setAttribute("referrerpolicy", "no-referrer"); - frame.srcdoc = text; + if (artifactOrigin && paneDoc && paneDoc.sha256) { + /* + * The page, on an origin of its own. + * + * `allow-same-origin` is what a page needs before it can be granted a + * camera, a microphone, or storage — permissions belong to origins, and + * the sandbox below hands out an opaque one, which can hold no grant. It + * is only safe to give here because of WHICH origin it means: a host that + * serves artifacts and nothing else, which the app's cookie never reaches + * and whose API is a 404. Same origin as nothing. + * + * It has to load by URL for that to be true — `srcdoc` inherits the + * embedder's origin, which is the one thing this must not be. + */ + frame.setAttribute("sandbox", "allow-scripts allow-same-origin"); + frame.setAttribute("allow", "camera; microphone; fullscreen"); + frame.src = artifactOrigin + "/a/" + encodeURIComponent(paneDoc.sha256); + } else { + // No such origin configured: the page runs inert, as it always has. + frame.setAttribute("sandbox", "allow-scripts"); + frame.srcdoc = text; + } body.appendChild(frame); } /** The source behind a rendered page, highlighted by the usual markdown path. */ @@ -5151,6 +5174,10 @@ var dataPromise = Promise.all([loadModels(), loadConversations()]); var me = await mePromise; if (!me) return; // redirecting to login setPfp(me); + // Where this deployment is willing to RUN a page-shaped artifact, if + // anywhere. Learned before the router mounts anything, so the first + // document opened is framed the same way as the hundredth. + artifactOrigin = me.artifactOrigin || ""; await dataPromise; // A project-scoped new chat landed on directly (/?new=1&project=): diff --git a/src/client/share.js b/src/client/share.js --- a/src/client/share.js +++ b/src/client/share.js @@ -34,12 +34,23 @@ * own pane does it: the two together would hand the document our origin back * and undo the sandbox, so they must never both appear. This matters more here * than in the app, because the reader is a stranger to whoever wrote the page. */ -function renderPage(text) { +function renderPage(text, meta) { var frame = document.createElement("iframe"); frame.className = "htmlframe"; - frame.setAttribute("sandbox", "allow-scripts"); frame.setAttribute("referrerpolicy", "no-referrer"); - frame.srcdoc = text; + if (meta && meta.artifactOrigin && meta.sha256) { + // Somewhere to run it that isn't here. `allow-same-origin` is what a page + // needs before a camera can be granted to it at all, and it is only safe + // because that origin serves documents and nothing else — no session, no + // API, nothing of this page's to reach. It must load by URL: `srcdoc` + // would inherit this origin, which is exactly what it must not have. + frame.setAttribute("sandbox", "allow-scripts allow-same-origin"); + frame.setAttribute("allow", "camera; microphone; fullscreen"); + frame.src = meta.artifactOrigin + "/a/" + encodeURIComponent(meta.sha256); + } else { + frame.setAttribute("sandbox", "allow-scripts"); + frame.srcdoc = text; + } $("shareBody").replaceChildren(frame); } @@ -47,7 +58,7 @@ function renderDoc(meta, text) { var body = $("shareBody"); if (/^text\/html\b/.test(meta.mime)) { body.classList.add("isframe"); - renderPage(text); + renderPage(text, meta); return; } var el = document.createElement("div"); diff --git a/src/http.ts b/src/http.ts --- a/src/http.ts +++ b/src/http.ts @@ -45,6 +45,7 @@ memoryRead, memoryWrite, } from "./lard"; import { flowFor } from "./oauthflows"; +import { offArtifactHost } from "./sandbox"; import { CredentialBody, MyModelBody, @@ -822,10 +823,14 @@ "/api/me": { GET: (req: Bun.BunRequest<"/api/me">) => { const s = getSession(req, store); // The role rides along so the UI can hide what it may not touch; the - // API gates on its own reading of the session either way. + // API gates on its own reading of the session either way. So does the + // artifact origin: it is a fact about the deployment the client needs + // at boot (it decides how a page-shaped artifact is framed), and this + // is the call the client already makes before anything renders. + const deployment = { artifactOrigin: getConfig().server.artifactOrigin || null }; return s - ? Response.json(sessionUser(s, store.getUserRole(s.sub))) - : Response.json({ authenticated: false, role: roleFor(undefined) }); + ? Response.json({ ...sessionUser(s, store.getUserRole(s.sub)), ...deployment }) + : Response.json({ authenticated: false, role: roleFor(undefined), ...deployment }); }, }, @@ -1543,7 +1548,9 @@ return Response.json({ ok: true }); }, }, }; - // When auth is enabled, every /api/* route requires a session (401 otherwise); - // /health stays open. A no-op when auth is off. - return gateApi(routes as never, store) as typeof routes; + // Two gates, both no-ops until something turns them on. When auth is enabled, + // every /api/* route requires a session (401 otherwise) and /health stays + // open. And when a separate artifact origin is configured, none of this + // answers there: that host serves artifacts and nothing else. + return offArtifactHost(gateApi(routes as never, store)) as typeof routes; } diff --git a/src/sandbox.ts b/src/sandbox.ts new file mode 100644 --- /dev/null +++ b/src/sandbox.ts @@ -0,0 +1,115 @@ +import type { BlobStore } from "./blobs"; +import { getConfig } from "./settings"; +import type { Store } from "./store"; + +/** + * A place to run an artifact that is not the app. + * + * An HTML artifact is model-authored code, so the app frames it sandboxed + * WITHOUT `allow-same-origin` — the one combination that must never appear + * beside `allow-scripts`, since together they would hand generated markup the + * user's own origin. The cost is that such a page has an opaque origin, and an + * opaque origin can hold no permission grant: no camera, no microphone, no + * storage, ever, however the page asks. + * + * The way out of that is not a weaker sandbox but a different origin. Serve the + * page from a host that owns nothing — no cookies, no API, no app — and + * `allow-same-origin` becomes safe to grant, because "same origin" now means + * that empty host. The camera can then be delegated to it explicitly, and what + * it is delegated FROM is a place with nothing to take. + * + * So this module is defined as much by what it refuses as by what it serves: + * + * - it answers only on the configured artifact host, so a proxy that sends + * the whole app there by mistake still cannot make it the app's origin; + * - it serves only HTML, because that is the one type this exists for; + * - it declares `frame-ancestors`, so only the app may frame what it serves. + * + * A sha256 is the whole address here — unguessable, and the same capability + * shape a publication token has. Worth saying plainly: it is a weaker rule than + * `/api/blobs`, which needs a session. It has to be, because the point of this + * origin is that the app's cookie never reaches it. + */ +export function sandboxRoutes(deps: { store: Store; blobs: BlobStore }) { + const { store, blobs } = deps; + const isSha256 = (s: string) => /^[0-9a-f]{64}$/.test(s); + + return { + "/a/:sha256": { + GET: async (req: Bun.BunRequest<"/a/:sha256">) => { + const origin = getConfig().server.artifactOrigin; + // Unset means this door does not exist. The frame keeps its old sandbox + // and the app is exactly as it was. + if (!origin) return new Response("not found", { status: 404 }); + if (!onArtifactHost(req)) return new Response("not found", { status: 404 }); + const sha256 = req.params.sha256; + if (!isSha256(sha256)) return new Response("not found", { status: 404 }); + const meta = store.getBlob(sha256); + // HTML only: everything else has a home already, behind the session. + if (!meta || !/^text\/html\b/i.test(meta.mime)) { + return new Response("not found", { status: 404 }); + } + const bytes = await blobs.get(sha256); + if (!bytes) return new Response("not found", { status: 404 }); + const app = getConfig().auth.baseUrl; + return new Response(bytes, { + headers: { + "Content-Type": meta.mime, + "X-Content-Type-Options": "nosniff", + // Content-addressed, so it can never change under a reader. + "Cache-Control": "public, max-age=31536000, immutable", + // A page that may run scripts should not also be a page anyone can + // frame: the app embeds it, and nobody else does. A deployment that + // never said where it lives (auth.baseUrl) gets no such line, since + // there is no honest value for it — the page is still walled off + // from everything by its origin, it just isn't told who may show it. + ...(app ? { "Content-Security-Policy": `frame-ancestors ${app}` } : {}), + "Referrer-Policy": "no-referrer", + }, + }); + }, + }, + }; +} + +/** Whether this request arrived on the artifact host rather than the app's. */ +export function onArtifactHost(req: Request): boolean { + const origin = getConfig().server.artifactOrigin; + if (!origin) return false; + try { + return new URL(req.url).host === new URL(origin).host; + } catch { + return false; + } +} + +type Handler = (req: never, ...rest: never[]) => Response | Promise; +type Routes = Record>; + +/** + * The app's API, refusing to answer on the artifact host. + * + * The proxy in front should send that hostname nothing but `/a/*`, and this is + * the same rule stated where a config change cannot edit it out. The isolation + * is worth what its weakest statement is worth, and a deployment with auth off + * would otherwise be serving an unauthenticated API to the very pages this + * arrangement exists to contain. + * + * Only the API and the public read paths need it. The app's own HTML carries no + * authority — served from the artifact host it would be a shell whose every API + * call 404s, which is a curiosity rather than a hole. + */ +export function offArtifactHost(routes: T): T { + const out: Routes = {}; + for (const [path, methods] of Object.entries(routes)) { + const wrapped: Record = {}; + for (const [method, fn] of Object.entries(methods)) { + wrapped[method] = (req, ...rest) => + onArtifactHost(req as unknown as Request) + ? new Response("not found", { status: 404 }) + : fn(req, ...rest); + } + out[path] = wrapped; + } + return out as T; +} diff --git a/src/settings.ts b/src/settings.ts --- a/src/settings.ts +++ b/src/settings.ts @@ -398,6 +398,26 @@ const ServerSchema = v.object({ port: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(65535)), 3000), dbPath: v.optional(v.string(), "data/kloe.db"), + /** + * A second origin to serve HTML artifacts from, e.g. + * "https://artifacts.kloe.example". Unset (the default) leaves them rendered + * exactly as they are today: `srcdoc` in a frame sandboxed WITHOUT + * `allow-same-origin`, which is what keeps model-authored HTML from reaching + * the app it is displayed in. + * + * That sandbox is also why such a page can never use the camera, or a + * microphone, or localStorage: those are granted per origin, and a frame + * without `allow-same-origin` has an opaque one, which can hold no grant. + * The way out is not to weaken the sandbox but to move the page somewhere + * that owns nothing — then "same origin" means the artifact host, and the + * app's cookies, storage and API are still a world away. + * + * The host must be one the app does not answer on (see sandboxRoutes) and + * that carries no cookie of the app's: kloe's session cookie sets no + * `Domain`, so a sibling subdomain never receives it. A wholly separate + * domain is stronger still — a subdomain can set cookies for the parent. + */ + artifactOrigin: v.optional(v.pipe(v.string(), v.regex(/^(https?:\/\/[^/\s]+)?$/)), ""), }); /** diff --git a/src/share.ts b/src/share.ts --- a/src/share.ts +++ b/src/share.ts @@ -1,4 +1,6 @@ import type { BlobStore } from "./blobs"; +import { offArtifactHost } from "./sandbox"; +import { getConfig } from "./settings"; import type { Store } from "./store"; /** @@ -20,7 +22,7 @@ const { store, blobs } = deps; // Tokens are hex UUIDs; anything else is rejected before it reaches SQL. const isToken = (t: string) => /^[0-9a-f]{32}$/.test(t); - return { + return offArtifactHost({ // What the share page needs to render itself: the title, the type, and how // big it is. Deliberately NOT the conversation it came from — a reader of a // shared document learns about the document and nothing else. @@ -36,6 +38,11 @@ mime: p.mime, size: p.size, version: p.version, createdAt: p.createdAt, + // Where a published page may be run as a page, when this deployment + // has somewhere safe to run one. The reader's side decides nothing: + // an absent origin means the old frame, sandboxed to inertness. + artifactOrigin: getConfig().server.artifactOrigin || null, + sha256: p.sha256, }, { headers: { "Cache-Control": "no-store" } }, ); @@ -66,7 +73,7 @@ if (ACTIVE_MIME.test(p.mime)) headers["Content-Security-Policy"] = "sandbox"; return new Response(bytes, { headers }); }, }, - }; + }); } /** Mimes a browser will execute script from when it renders them as a document. */ diff --git a/tests/sandbox.test.ts b/tests/sandbox.test.ts new file mode 100644 --- /dev/null +++ b/tests/sandbox.test.ts @@ -0,0 +1,128 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { FsBlobStore } from "../src/blobs"; +import { apiRoutes } from "../src/http"; +import { sandboxRoutes } from "../src/sandbox"; +import { loadConfig, setConfig } from "../src/settings"; +import { shareRoutes } from "../src/share"; +import { Store } from "../src/store"; + +/** + * The artifact origin exists to be a place with nothing in it. + * + * So the tests worth having are the refusals: that it serves nothing but the + * pages it is for, that the app refuses to answer there, and that a deployment + * which never configured it is exactly as it was. + */ +const ARTIFACTS = "https://artifacts.kloe.test"; + +afterEach(() => { + setConfig(null); +}); + +/** A deployment that knows where it lives and (maybe) where its artifacts run. */ +function configure(artifactOrigin: string, appOrigin = "https://kloe.test"): void { + const base = loadConfig({ path: "does-not-exist.json", env: {} }); + setConfig({ + ...base, + server: { ...base.server, artifactOrigin }, + auth: { ...base.auth, baseUrl: appOrigin }, + }); +} + +function serve(store: Store, blobs: FsBlobStore) { + return Bun.serve({ + port: 0, + routes: { + ...sandboxRoutes({ store, blobs }), + ...shareRoutes({ store, blobs }), + ...apiRoutes({ store, blobs }), + }, + }); +} + +async function withServer( + fn: (base: string, store: Store, blobs: FsBlobStore) => Promise, +): Promise { + const tmp = mkdtempSync(join(tmpdir(), "kloe-sandbox-")); + const store = new Store(":memory:"); + const blobs = new FsBlobStore(join(tmp, "blobs")); + const server = serve(store, blobs); + try { + await fn(server.url.origin, store, blobs); + } finally { + server.stop(true); + store.db.close(); + rmSync(tmp, { recursive: true, force: true }); + } +} + +/** Stores bytes as an artifact-shaped blob and returns its address. */ +async function putHtml(store: Store, blobs: FsBlobStore, html: string): Promise { + const { sha256, size } = await blobs.put(new TextEncoder().encode(html)); + store.recordBlob(sha256, "text/html", size); + return sha256; +} + +test("with no artifact origin configured, the door is not there", async () => { + configure(""); + await withServer(async (base, store, blobs) => { + const sha = await putHtml(store, blobs, "

hello

"); + expect((await fetch(`${base}/a/${sha}`)).status).toBe(404); + }); +}); + +test("the artifact origin serves a page, and says who may frame it", async () => { + await withServer(async (base, store, blobs) => { + // This server IS the artifact host for the length of the test; what the + // route matches on is the host the request arrived at. + configure(base); + const sha = await putHtml(store, blobs, "

a page

"); + const res = await fetch(`${base}/a/${sha}`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(await res.text()).toBe("

a page

"); + // Not the `sandbox` CSP the app's own blob route sets: this page has to be + // able to run, which is the whole reason this origin exists. + const csp = res.headers.get("content-security-policy") ?? ""; + expect(csp).not.toContain("sandbox"); + // Only the app may frame it. + expect(csp).toContain("frame-ancestors https://kloe.test"); + }); +}); + +test("it serves only HTML, and only on its own host", async () => { + await withServer(async (base, store, blobs) => { + configure(base); + const { sha256, size } = await blobs.put(new TextEncoder().encode("not a page")); + store.recordBlob(sha256, "text/plain", size); + // Anything that isn't a page has a home already, behind the session. + expect((await fetch(`${base}/a/${sha256}`)).status).toBe(404); + expect((await fetch(`${base}/a/nonsense`)).status).toBe(404); + + // And on any other host, the door isn't there at all. + const html = await putHtml(store, blobs, "

a page

"); + configure(ARTIFACTS); + expect((await fetch(`${base}/a/${html}`)).status).toBe(404); + }); +}); + +test("the app does not answer on the artifact host", async () => { + // The isolation is worth what its weakest statement is worth. A proxy that + // sent the whole app to that hostname must not make it the app's origin — + // with auth off, that would be an unauthenticated API served to the very + // pages this arrangement exists to contain. + await withServer(async (base, store, blobs) => { + configure("http://localhost:1"); // not this server: the app answers normally + expect((await fetch(`${base}/api/conversations`)).status).toBe(200); + + configure(base); // now this server IS the artifact host + expect((await fetch(`${base}/api/conversations`)).status).toBe(404); + expect((await fetch(`${base}/api/me`)).status).toBe(404); + // …but the one thing it is for still works. + const sha = await putHtml(store, blobs, "

still here

"); + expect((await fetch(`${base}/a/${sha}`)).status).toBe(200); + }); +});