/** * Security limits: the model gate (per-IP concurrency + the account-wide * budget), the data-URL contract, the proxy-hop identity, the disk guards and * the response headers. * * Runs against its own instance with a slow stub Ollama and deliberately tiny * limits, so every guard is observable in a second or two. */ import http from "node:http"; import { mkdirSync, readFileSync, rmSync } from "node:fs"; import { launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs"; const { check, finish } = makeChecker(); const STUB_PORT = 11997; const APP_PORT = 3210; const STUB_BASE = `http://127.0.0.1:${STUB_PORT}`; const APP = `http://127.0.0.1:${APP_PORT}`; const REPORTS_DIR = new URL("./.tmp/security-reports", import.meta.url).pathname; const PUSH_DIR = new URL("./.tmp/security-push", import.meta.url).pathname; /* A deliberately slow stub: model calls stay in flight long enough to test the concurrency gate, and the payload itself never matters here. */ const stub = http.createServer((request, response) => { if (request.url?.startsWith("/api/tags")) { response.writeHead(200, { "Content-Type": "application/json" }); response.end(JSON.stringify({ models: [{ name: "stub", model: "stub" }] })); return; } request.on("data", () => {}); request.on("end", () => { setTimeout(() => { response.writeHead(200, { "Content-Type": "application/json" }); response.end(JSON.stringify({ message: { content: "{}" } })); }, 700); }); }); await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve)); rmSync(REPORTS_DIR, { recursive: true, force: true }); rmSync(PUSH_DIR, { recursive: true, force: true }); mkdirSync(REPORTS_DIR, { recursive: true }); mkdirSync(PUSH_DIR, { recursive: true }); check("security app port is free", !(await portInUse(APP_PORT))); const app = spawnApp(APP_PORT, { OLLAMA_BASE_URL: STUB_BASE, OLLAMA_AUTH: "local", OLLAMA_VERIFY_KEY: "false", OLLAMA_TIMEOUT_MS: "10000", RATE_LIMIT_CONCURRENT: "2", RATE_LIMIT_CONCURRENT_TOTAL: "3", RATE_LIMIT_MODEL_TOTAL: "6", RATE_LIMIT_REPORTS: "1", REPORTS_DIR, REPORTS_MAX_BYTES: "900", PUSH_DIR, PUSH_MAX_SUBSCRIPTIONS: "2", VAPID_PUBLIC_KEY: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U", VAPID_PRIVATE_KEY: "UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls", VAPID_SUBJECT: "mailto:test@example.com", }); const browser = await launchBrowser(); try { const up = await waitForHttp(`${APP}/api/health`); check("security app instance is up", up); if (up) { /* 1. per-IP concurrency: only N model calls in flight ---------------- */ const generate = (body) => fetch(`${APP}/api/generate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Photosynthesis needs light and water.", ...body }), }); const parallel = await Promise.all([generate({}), generate({}), generate({})]); const statuses = parallel.map((response) => response.status).sort(); check( "concurrency: the third parallel model call is refused as busy", statuses.filter((status) => status === 200).length === 2 && statuses.includes(429), JSON.stringify(statuses), ); const busy = await parallel.find((response) => response.status === 429)?.json(); check("concurrency: the refusal is a friendly busy 429", busy?.kind === "busy" && typeof busy?.error === "string", JSON.stringify(busy)); // Drain the two streams so their slots are released. await Promise.all(parallel.map((response) => response.text().catch(() => ""))); /* 2. the account-wide budget: even sequential calls stop ------------ Two calls were admitted above (the refused one never counted), and the data-URL call below adds one — so with a budget of six, three more get through and the next is refused. */ const plainUrl = await fetch(`${APP}/api/generate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ images: [`http://169.254.169.254/latest/meta-data/`, "x".repeat(40)] }), }); check("data URLs: a plain URL is rejected", plainUrl.status === 400, String(plainUrl.status)); const dataUrl = await fetch(`${APP}/api/generate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ images: [`data:image/jpeg;base64,${"A".repeat(200)}`] }), }); check("data URLs: a real data URL is accepted", dataUrl.status === 200, String(dataUrl.status)); await dataUrl.text().catch(() => ""); const admitted = []; for (let index = 0; index < 3; index += 1) { const response = await generate({}); admitted.push(response.status); await response.text().catch(() => ""); } const overBudget = await generate({}); const overBudgetBody = overBudget.status === 429 ? await overBudget.json() : null; check( "budget: the model-call budget stops a flood", admitted.every((status) => status === 200) && overBudget.status === 429 && overBudgetBody?.kind === "rate_limited", `${JSON.stringify(admitted)} then ${overBudget.status} ${JSON.stringify(overBudgetBody)}`, ); /* 4. the rate-limit identity is the last proxy hop ------------------ */ const report = (forwarded) => fetch(`${APP}/api/reports`, { method: "POST", headers: { "Content-Type": "application/json", "x-forwarded-for": forwarded }, body: JSON.stringify({ deckId: "seed_deck", activityId: "a1", type: "multiple_choice", question: "What colour is a clear daytime sky?", at: Date.now(), }), }); const spoofOne = await report("9.9.9.9, 10.0.0.1"); const spoofTwo = await report("8.8.8.8, 10.0.0.1"); const other = await report("7.7.7.7, 10.0.0.2"); check( "identity: a spoofed x-forwarded-for cannot dodge the per-IP limit", spoofOne.status === 200 && spoofTwo.status === 429 && other.status === 200, `${spoofOne.status}/${spoofTwo.status}/${other.status}`, ); /* 5. the reports file stops growing at its cap ---------------------- */ for (let index = 0; index < 6; index += 1) { await report(`7.7.7.7, 10.0.0.${10 + index}`); } const reportsSize = readFileSync(`${REPORTS_DIR}/reports.jsonl`, "utf8").length; check("reports: the JSONL stops at its size cap", reportsSize > 0 && reportsSize < 2000, `${reportsSize} bytes`); /* 6. push endpoints must be real push services ---------------------- */ const subscribe = (endpoint) => fetch(`${APP}/api/push`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint, keys: { p256dh: "B".repeat(40), auth: "C".repeat(20) } }), }); const evil = await subscribe("https://evil.example/collect"); check("push: an arbitrary endpoint is rejected (no SSRF)", evil.status === 400, String(evil.status)); const good = await subscribe("https://fcm.googleapis.com/fcm/send/abc"); const goodSecond = await subscribe("https://updates.push.services.mozilla.com/wpush/v2/abc"); const overCap = await subscribe("https://web.push.apple.com/abc"); check( "push: subscriptions are capped", good.status === 200 && goodSecond.status === 200 && overCap.status === 429, `${good.status}/${goodSecond.status}/${overCap.status}`, ); /* 7. response headers ------------------------------------------------ */ const { context, page } = await makePage(browser); await seed(page, { decks: [] }); await page.goto(`${APP}/`, { waitUntil: "domcontentloaded" }); const headers = await page.evaluate(async () => { const response = await fetch("/", { cache: "no-store" }); return { frame: response.headers.get("x-frame-options"), csp: response.headers.get("content-security-policy"), nosniff: response.headers.get("x-content-type-options"), referrer: response.headers.get("referrer-policy"), }; }); check( "headers: the app refuses to be framed and sniffed", headers.frame === "DENY" && (headers.csp ?? "").includes("frame-ancestors 'none'") && headers.nosniff === "nosniff" && headers.referrer === "strict-origin-when-cross-origin", JSON.stringify(headers), ); /* 8. cross-origin writes stay rejected ------------------------------- */ const crossOrigin = await fetch(`${APP}/api/reports`, { method: "POST", headers: { "Content-Type": "application/json", Origin: "https://evil.example" }, body: JSON.stringify({ deckId: "d", activityId: "a", type: "multiple_choice", question: "q", at: Date.now() }), }); check("cross-origin POST is refused", crossOrigin.status === 403, String(crossOrigin.status)); await context.close(); } } finally { app.stop(); stub.close(); await browser.close(); } finish();