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.
208 lines
8.9 KiB
JavaScript
208 lines
8.9 KiB
JavaScript
/**
|
|
* 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();
|