Security: model budget + concurrency caps, SSRF guards, spoof-proof limits

Audit pass with the threats that actually apply to a self-hosted, account-less
app: getting the Ollama key throttled or banned, and someone grinding the app
down. Per-route windows already existed; these are the missing layers.

- lib/model-guard.ts: every route that spends a model call now takes a slot —
  60 calls per window across all clients (RATE_LIMIT_MODEL_TOTAL), 3 in flight
  per IP, 6 in total — and the slot is released when the call finishes, on
  errors and on client disconnects. Over the concurrency cap answers 429
  kind=busy, not a punishment. Slots are taken after every cheap rejection so
  a malformed request cannot hold one (found by the new suite: early 400s
  were leaking slots).
- Images must be base64 data URLs (dataUrlSchema in lib/schemas): a plain URL
  in an images field made Ollama fetch an arbitrary address on our behalf.
- Push endpoints must be a real push service (PUSH_ALLOWED_HOSTS, defaulting
  to FCM/Mozilla/Apple/WNS): the server POSTs to whatever is stored.
- The rate-limit identity is the LAST x-forwarded-for hop. Reading the first
  let a client spoof its own IP and dodge every per-IP limit.
- Disk guards: reports.jsonl stops at REPORTS_MAX_BYTES (8 MB), reminder
  devices cap at PUSH_MAX_SUBSCRIPTIONS (50).
- Security headers: frame-ancestors 'none' + X-Frame-Options DENY, nosniff, a
  referrer policy and a Permissions-Policy without camera/mic/geo/payment.

New tests/security-test.mjs (13 checks, registered in run-all) proves each
guard against a slow stub Ollama with deliberately tiny limits. README:
security notes + rate-limit tables updated. Version 0.1.3, SW -> v51.
This commit is contained in:
Martin Dvořák 2026-10-12 20:40:57 +00:00
commit 8822baf4b7
21 changed files with 528 additions and 21 deletions

View file

@ -447,6 +447,8 @@ const browser = await launchBrowser();
VAPID_PRIVATE_KEY: vapid.privateKey,
VAPID_SUBJECT: "mailto:test@example.com",
PUSH_DIR: path.join(REPO, "tests", ".tmp", "push"),
// The endpoint in this suite is a fake host: allow it explicitly.
PUSH_ALLOWED_HOSTS: "push.example.invalid",
});
try {

View file

@ -22,6 +22,7 @@ const SUITES = [
"phase1-test",
"phase2-test",
"phase3-test",
"security-test",
"chat-test",
"review-test",
];

208
tests/security-test.mjs Normal file
View file

@ -0,0 +1,208 @@
/**
* 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();