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:
parent
aac6befb0d
commit
8822baf4b7
21 changed files with 528 additions and 21 deletions
208
tests/security-test.mjs
Normal file
208
tests/security-test.mjs
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue