From 8822baf4b794d1654984ccabfd09dd58f4be7b1b Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Mon, 12 Oct 2026 20:40:57 +0000 Subject: [PATCH] Security: model budget + concurrency caps, SSRF guards, spoof-proof limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 44 ++++++- app/api/chat/extract/route.ts | 7 +- app/api/chat/route.ts | 8 ++ app/api/generate/route.ts | 12 +- app/api/grade/route.ts | 5 + app/api/homework/refine/route.ts | 5 + app/api/homework/route.ts | 14 ++- app/api/push/route.ts | 33 ++++- app/api/reports/route.ts | 11 +- lib/config.ts | 38 ++++++ lib/i18n-dict.ts | 6 + lib/model-guard.ts | 60 +++++++++ lib/push-store.ts | 13 +- lib/rate-limit.ts | 45 ++++++- lib/schemas.ts | 13 ++ next.config.ts | 20 +++ package.json | 2 +- public/sw.js | 2 +- tests/phase2-test.mjs | 2 + tests/run-all.mjs | 1 + tests/security-test.mjs | 208 +++++++++++++++++++++++++++++++ 21 files changed, 528 insertions(+), 21 deletions(-) create mode 100644 lib/model-guard.ts create mode 100644 tests/security-test.mjs diff --git a/README.md b/README.md index d80e47b..c6fa488 100644 --- a/README.md +++ b/README.md @@ -454,10 +454,31 @@ client IP with a sliding window. Everything is configurable in `.env.local`: | `GET/POST/DELETE /api/push`, `POST /api/push/test` | 30 per 5 minutes | `RATE_LIMIT_PUSH` | | window for all of them | 5 minutes | `RATE_LIMIT_WINDOW_MS` | +On top of the per-route windows there is one **account-level guard**, so no flood — from one client +or from many — can get the Ollama key throttled or banned: + +| Guard | Default | Env var | +| --- | --- | --- | +| all model calls together (generate, homework, refine, chat, extract, grade) | 60 per window | `RATE_LIMIT_MODEL_TOTAL` | +| model calls in flight per client IP | 3 | `RATE_LIMIT_CONCURRENT` | +| model calls in flight across all clients | 6 | `RATE_LIMIT_CONCURRENT_TOTAL` | + +A request over the concurrency cap gets a **429 `kind: "busy"`** (“Freezmo is already working on +something — try again in a moment”), not a punishment: the slot frees as soon as the model call +finishes, and the client's Retry button works. Slots are taken *after* every cheap rejection, so a +malformed request can never hold one. + +Two more disk guards: `REPORTS_MAX_BYTES` (default 8 MB) stops appending to `reports.jsonl` once it +is full, and `PUSH_MAX_SUBSCRIPTIONS` (default 50) refuses new reminder devices over the cap. +Reminder endpoints must also be a real push service — `fcm.googleapis.com`, +`updates.push.services.mozilla.com`, `*.push.apple.com`, `*.notify.windows.com` — or a host you add +with `PUSH_ALLOWED_HOSTS`, because the server POSTs to whatever is stored. + When a limit is hit the route answers **429** with a `Retry-After` header and a friendly message (“Too many requests — try again in 42s”); the UI shows that message and keeps the Retry button. The -client IP is taken from `x-forwarded-for` / `x-real-ip` when present (reverse proxy), otherwise all -local clients share one bucket. +client IP is taken from the **last** `x-forwarded-for` hop (the one your reverse proxy appended) or +from `x-real-ip`; taking the first hop would let a client spoof its own identity and dodge every +per-IP limit. Requests without those headers share one bucket. Caveats, stated honestly: counters live **in memory**, so they reset on restart and are not shared between multiple instances or Node workers. That is fine for a self-hosted single-instance app; if @@ -658,6 +679,19 @@ self-hosted study app with no accounts, no database and no server-side state. parsing, on top of the zod limits on image count, text length and field sizes. - **Rate limiting** per client IP; see [Rate limits](#rate-limits). Auth is checked *before* parsing request bodies, so unauthenticated traffic costs almost nothing. +- **An account-level model budget and concurrency caps** sit in front of every route that spends an + Ollama call (`lib/model-guard.ts`): 60 calls per window across all clients, 3 in flight per IP, 6 + in flight in total. A flood cannot pile up parallel calls or burn the account — the thing that + gets a key throttled or banned. Slots are released when the call finishes, including a client that + disconnects mid-stream. +- **Images must be base64 data URLs** (`dataUrlSchema`). A plain URL in an `images` field would make + Ollama fetch an arbitrary address on our behalf, so the shape is part of the contract. +- **Reminder endpoints must be real push services** (an allowlist, `PUSH_ALLOWED_HOSTS`): the server + POSTs to whatever is stored, so an arbitrary URL there would be an SSRF primitive. +- **Security headers** on every response: `X-Frame-Options: DENY`, `Content-Security-Policy: + frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, a referrer policy and a + Permissions-Policy that turns off camera/microphone/geolocation/payment (the camera button is a + file input with `capture`, which is not gated by that policy). - **No CSRF surface**: no cookies, no sessions, no server-side mutations. - **Prompt injection is low impact by design.** Your notes could contain text that tries to manipulate the model, but the model can only return a JSON deck (validated per activity, dropped if @@ -670,8 +704,10 @@ self-hosted study app with no accounts, no database and no server-side state. notes. - Anything stored in `localStorage` is readable by JavaScript on the same origin. Freezmo has no XSS vector today, but that is the standard risk of browser-stored credentials. -- Rate limiting trusts `x-forwarded-for` when present (correct behind a proxy, spoofable when - exposed directly) and keeps counters in memory: single instance only, reset on restart. +- Rate limiting keeps counters in memory: single instance only, reset on restart. The client IP is + read from the **last** `x-forwarded-for` hop, so a client cannot spoof its identity — but behind + more than one proxy the "last hop" is the proxy closest to Freezmo, which means those clients + share one bucket. Point `x-real-ip` at the real client if you need per-client buckets there. - Ollama cloud usage is billed to whoever's key is used, so treat a shared Freezmo URL as shared key access in `server` mode. - No accounts means no per-user isolation: anyone who can reach the URL can use the app (and, in diff --git a/app/api/chat/extract/route.ts b/app/api/chat/extract/route.ts index 4b93cfa..279e4f4 100644 --- a/app/api/chat/extract/route.ts +++ b/app/api/chat/extract/route.ts @@ -5,6 +5,8 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@ import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; +import { dataUrlSchema } from "@/lib/schemas"; import { translate } from "@/lib/i18n-dict"; export const runtime = "nodejs"; @@ -16,7 +18,7 @@ const MAX_BODY_BYTES = 33554432; const MAX_IMAGE_CHARS = 8_000_000; const requestSchema = z.object({ - images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), + images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), text: z.string().max(120_000).optional(), }); @@ -66,6 +68,8 @@ export async function POST(request: Request) { return Response.json({ error: translate(locale, "server.needContent") }, { status: 400 }); } + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; const abort = new AbortController(); const timer = setTimeout(() => abort.abort(), config.timeoutMs); try { @@ -97,6 +101,7 @@ export async function POST(request: Request) { ); } finally { clearTimeout(timer); + gate.gate.release(); } } diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 9e03022..ae86e43 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -4,6 +4,7 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM import { friendlyErrorMessage, ollamaChatStream, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; import { translate } from "@/lib/i18n-dict"; import { buildChatSystemPrompt } from "@/lib/prompts"; import { estimateTokens, trimHistory } from "@/lib/chat-trim"; @@ -131,6 +132,11 @@ export async function POST(request: Request) { })), }); + // Concurrency + the account-wide model budget (lib/model-guard), taken + // after every cheap rejection so a 400 can never leak a slot. + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; + const encode = encoder(); const abort = new AbortController(); const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs); @@ -170,6 +176,7 @@ export async function POST(request: Request) { } } finally { clearTimeout(globalTimer); + gate.gate.release(); try { controller.close(); } catch { @@ -179,6 +186,7 @@ export async function POST(request: Request) { }, cancel() { clearTimeout(globalTimer); + gate.gate.release(); abort.abort(); }, }); diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts index 7c5e9e3..82c3951 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -5,8 +5,9 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; import { translate } from "@/lib/i18n-dict"; -import { difficultySchema } from "@/lib/schemas"; +import { dataUrlSchema, difficultySchema } from "@/lib/schemas"; import { uid } from "@/lib/utils"; import { xpForActivity } from "@/lib/xp"; @@ -18,7 +19,7 @@ export const maxDuration = 300; const MAX_IMAGE_CHARS = 8_000_000; const requestSchema = z.object({ - images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), + images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), text: z.string().max(120_000).optional(), difficulty: difficultySchema.default("medium"), count: z.number().int().min(4).max(18).optional(), @@ -101,6 +102,11 @@ export async function POST(request: Request) { ); } + // Concurrency + the account-wide model budget (lib/model-guard). Taken + // only now, after every cheap rejection, so a 400 can never leak a slot. + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; + const requestedCount = count ?? config.defaultActivityCount; const encode = encoder(); const abort = new AbortController(); @@ -201,6 +207,7 @@ export async function POST(request: Request) { }); } finally { clearTimeout(globalTimer); + gate.gate.release(); try { controller.close(); } catch { @@ -210,6 +217,7 @@ export async function POST(request: Request) { }, cancel() { clearTimeout(globalTimer); + gate.gate.release(); abort.abort(); }, }); diff --git a/app/api/grade/route.ts b/app/api/grade/route.ts index a04c46c..8590364 100644 --- a/app/api/grade/route.ts +++ b/app/api/grade/route.ts @@ -5,6 +5,7 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@ import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; import { translate } from "@/lib/i18n-dict"; export const runtime = "nodejs"; @@ -54,6 +55,8 @@ export async function POST(request: Request) { return Response.json({ error: translate(locale, "server.invalidGrade") }, { status: 400 }); } + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; try { const result = await gradeShortAnswer({ model: config.textModel, @@ -72,5 +75,7 @@ export async function POST(request: Request) { }, { status: 502 }, ); + } finally { + gate.gate.release(); } } diff --git a/app/api/homework/refine/route.ts b/app/api/homework/refine/route.ts index 8fc04ad..0435e98 100644 --- a/app/api/homework/refine/route.ts +++ b/app/api/homework/refine/route.ts @@ -6,6 +6,7 @@ import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { translate } from "@/lib/i18n-dict"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -64,6 +65,8 @@ export async function POST(request: Request) { ); } + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; try { const text = await refineHomeworkText({ model: config.textModel, @@ -86,5 +89,7 @@ export async function POST(request: Request) { }, { status: 502 }, ); + } finally { + gate.gate.release(); } } diff --git a/app/api/homework/route.ts b/app/api/homework/route.ts index 1a0a535..761a603 100644 --- a/app/api/homework/route.ts +++ b/app/api/homework/route.ts @@ -5,8 +5,9 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; import { translate } from "@/lib/i18n-dict"; -import { homeworkModeSchema, type HomeworkStep } from "@/lib/schemas"; +import { dataUrlSchema, homeworkModeSchema, type HomeworkStep } from "@/lib/schemas"; import { uid } from "@/lib/utils"; export const runtime = "nodejs"; @@ -20,12 +21,12 @@ const MAX_BODY_BYTES = 33554432; const requestSchema = z.object({ mode: homeworkModeSchema, - images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), + images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), text: z.string().max(120_000).optional(), /** Mode "check": the student's own attempt at the assignment. */ solution: z.string().max(120_000).optional(), /** Mode "check": photos of the attempt (handwritten work, scanned pages). */ - solutionImages: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), + solutionImages: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), /** Language of the assignment (usually detected by the vision step). */ language: z.string().max(60).optional(), }); @@ -95,6 +96,11 @@ export async function POST(request: Request) { return Response.json({ error: translate(locale, "server.needSolution") }, { status: 400 }); } + // Concurrency + the account-wide model budget (lib/model-guard), taken + // after every cheap rejection so a 400 can never leak a slot. + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; + const encode = encoder(); const abort = new AbortController(); // A single hard deadline for the whole pipeline (two sequential model calls). @@ -257,6 +263,7 @@ export async function POST(request: Request) { }); } finally { clearTimeout(globalTimer); + gate.gate.release(); try { controller.close(); } catch { @@ -266,6 +273,7 @@ export async function POST(request: Request) { }, cancel() { clearTimeout(globalTimer); + gate.gate.release(); abort.abort(); }, }); diff --git a/app/api/push/route.ts b/app/api/push/route.ts index e7b8c78..d8c8451 100644 --- a/app/api/push/route.ts +++ b/app/api/push/route.ts @@ -16,6 +16,23 @@ export const dynamic = "force-dynamic"; * is false — no half-configured UI ever shows. */ +/** + * The server POSTs to the stored endpoint with VAPID headers, so it must be a + * real push service: an arbitrary URL here would be an SSRF primitive. The + * allowed hosts come from the config (PUSH_ALLOWED_HOSTS), defaulting to the + * real services. + */ +function isPushEndpoint(value: string, allowedHosts: string[]): boolean { + try { + const url = new URL(value); + if (url.protocol !== "https:") return false; + const host = url.hostname.toLowerCase(); + return allowedHosts.some((entry) => (entry.startsWith(".") ? host.endsWith(entry) : host === entry)); + } catch { + return false; + } +} + const subscriptionSchema = z.object({ endpoint: z.string().url().max(2000), keys: z.object({ @@ -96,9 +113,21 @@ export async function POST(request: Request) { { status: 400 }, ); } + if (!isPushEndpoint(parsed.data.endpoint, config.push.allowedHosts)) { + return Response.json( + { error: translate(locale, "server.pushEndpoint"), kind: "bad_endpoint" }, + { status: 400 }, + ); + } - const count = await addSubscription(config.push.dir, parsed.data); - return Response.json({ ok: true, count }, { headers: { "Cache-Control": "no-store" } }); + const count = await addSubscription(config.push.dir, parsed.data, config.push.maxSubscriptions); + if (!count.accepted) { + return Response.json( + { error: translate(locale, "server.pushTooMany", { max: config.push.maxSubscriptions }), kind: "too_many" }, + { status: 429 }, + ); + } + return Response.json({ ok: true, count: count.count }, { headers: { "Cache-Control": "no-store" } }); } export async function DELETE(request: Request) { diff --git a/app/api/reports/route.ts b/app/api/reports/route.ts index 263d246..bb92e7e 100644 --- a/app/api/reports/route.ts +++ b/app/api/reports/route.ts @@ -51,7 +51,16 @@ export async function POST(request: Request) { const line = JSON.stringify({ ...parsed.data, receivedAt: new Date().toISOString() }); try { await fs.mkdir(config.reports.dir, { recursive: true }); - await fs.appendFile(path.join(config.reports.dir, "reports.jsonl"), `${line}\n`, "utf8"); + const file = path.join(config.reports.dir, "reports.jsonl"); + /* Disk guard: reports are a convenience for the operator, so once the + file passes the cap the app stops appending (the browser's own list is + still the source of truth for skipping). */ + const size = await fs.stat(file).then((stats) => stats.size).catch(() => 0); + if (size >= config.reports.maxBytes) { + console.warn("[api/reports] reports.jsonl is at its size cap — not appending"); + return Response.json({ ok: true }); + } + await fs.appendFile(file, `${line}\n`, "utf8"); } catch { return Response.json({ error: "could not store the report" }, { status: 500 }); } diff --git a/lib/config.ts b/lib/config.ts index 1e4468a..6d76cfd 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -37,6 +37,12 @@ export type ServerConfig = { push: number; chat: number; reports: number; + /** All Ollama calls together per window (account-level guard). */ + modelTotal: number; + /** In-flight model calls per client IP. */ + concurrent: number; + /** In-flight model calls across all clients. */ + concurrentTotal: number; }; sync: { /** Where opaque encrypted sync blobs live (SYNC_DIR). */ @@ -53,10 +59,20 @@ export type ServerConfig = { subject: string; /** Where push subscriptions live (PUSH_DIR). */ dir: string; + /** Cap on stored subscriptions (PUSH_MAX_SUBSCRIPTIONS). */ + maxSubscriptions: number; + /** + * Hosts the server is allowed to POST reminders to (PUSH_ALLOWED_HOSTS, + * comma separated). Defaults to the real push services; a self-hosted + * push service can be added, and the test suite points it at its own stub. + */ + allowedHosts: string[]; }; reports: { /** Where reported questions are appended, one JSON line each (REPORTS_DIR). */ dir: string; + /** Stop appending past this size (REPORTS_MAX_BYTES) — disk guard. */ + maxBytes: number; }; }; @@ -156,6 +172,12 @@ export function getServerConfig(): ServerConfig { chat: positiveInt(process.env.RATE_LIMIT_CHAT, 60), // Question reports are cheap file appends. reports: positiveInt(process.env.RATE_LIMIT_REPORTS, 20), + // Every Ollama call, all clients together: the account-level guard. A + // flood would otherwise get the key throttled or banned. + modelTotal: positiveInt(process.env.RATE_LIMIT_MODEL_TOTAL, 60), + // In-flight model calls: per client IP, and across all clients. + concurrent: positiveInt(process.env.RATE_LIMIT_CONCURRENT, 3), + concurrentTotal: positiveInt(process.env.RATE_LIMIT_CONCURRENT_TOTAL, 6), }, sync: { dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "sync"), @@ -167,9 +189,25 @@ export function getServerConfig(): ServerConfig { privateKey: process.env.VAPID_PRIVATE_KEY?.trim() || "", subject: process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost", dir: process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push"), + maxSubscriptions: positiveInt(process.env.PUSH_MAX_SUBSCRIPTIONS, 50), + allowedHosts: (process.env.PUSH_ALLOWED_HOSTS ?? "") + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean) + .concat( + process.env.PUSH_ALLOWED_HOSTS + ? [] + : [ + "fcm.googleapis.com", + "updates.push.services.mozilla.com", + ".push.apple.com", + ".notify.windows.com", + ], + ), }, reports: { dir: process.env.REPORTS_DIR?.trim() || path.join(process.cwd(), ".freezmo", "reports"), + maxBytes: positiveInt(process.env.REPORTS_MAX_BYTES, 8 * 1024 * 1024), }, }; } diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index bf4844f..583f4f2 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -742,6 +742,9 @@ const en: Record = { "server.invalidOrigin": "Invalid Origin header.", "server.crossOrigin": "Cross-origin requests are not allowed.", "server.rateLimited": "Too many requests — this app allows {limit} per window. Try again in {retry}s.", + "server.busy": "Freezmo is already working on something — try again in a moment.", + "server.pushTooMany": "This server already stores {max} reminder devices — remove one first.", + "server.pushEndpoint": "That is not a push service this server is allowed to send to.", "server.authMissingKey": "Add your Ollama API key first — open the settings dialog (⚙️) and paste a key from ollama.com/settings/keys.", "server.authServerKeyMissing": @@ -1681,6 +1684,9 @@ const cs: Record = { "server.invalidOrigin": "Neplatná hlavička Origin.", "server.crossOrigin": "Požadavky z jiné domény nejsou povoleny.", "server.rateLimited": "Příliš mnoho požadavků — aplikace povoluje {limit} za okno. Zkus to znovu za {retry} s.", + "server.busy": "Freezmo už na něčem pracuje — zkus to za chvilku znovu.", + "server.pushTooMany": "Server už má uložených {max} zařízení pro připomínky — nejdřív nějaké odeber.", + "server.pushEndpoint": "Na tuhle službu server připomínky posílat nesmí.", "server.authMissingKey": "Nejdřív přidej svůj API klíč Ollama — otevři nastavení (⚙️) a vlož klíč z ollama.com/settings/keys.", "server.authServerKeyMissing": diff --git a/lib/model-guard.ts b/lib/model-guard.ts new file mode 100644 index 0000000..bd868a8 --- /dev/null +++ b/lib/model-guard.ts @@ -0,0 +1,60 @@ +import type { ServerConfig } from "./config"; +import { translate, type Locale } from "./i18n-dict"; +import { acquireSlot, clientKey, rateLimit, rateLimitResponse, releaseSlot } from "./rate-limit"; + +/** + * One gate in front of every route that spends an Ollama call: + * + * - a **global budget** per window (all clients together) so no flood can + * burn through the account — the thing that gets a key throttled or banned; + * - a **per-IP concurrency cap** and a **global one**, so a burst of parallel + * requests can't pile up model calls (or sockets and memory). + * + * The slot is released once, when the response is finished — including a + * client that disconnects mid-stream, and including routes that throw. + */ +export type ModelGate = { release: () => void }; + +export function acquireModelGate( + request: Request, + config: ServerConfig, + locale: Locale, +): { gate: ModelGate } | { response: Response } { + const perIpKey = `model-ip:${clientKey(request)}`; + const totalKey = "model-total"; + + if (!acquireSlot(totalKey, config.rateLimits.concurrentTotal)) { + return { response: busyResponse(locale) }; + } + if (!acquireSlot(perIpKey, config.rateLimits.concurrent)) { + releaseSlot(totalKey); + return { response: busyResponse(locale) }; + } + + const budget = rateLimit("model-budget", config.rateLimits.modelTotal, config.rateLimits.windowMs); + if (!budget.allowed) { + releaseSlot(perIpKey); + releaseSlot(totalKey); + return { response: rateLimitResponse(budget, locale) }; + } + + let released = false; + return { + gate: { + release: () => { + if (released) return; + released = true; + releaseSlot(perIpKey); + releaseSlot(totalKey); + }, + }, + }; +} + +/** 429 for "something is already running" — not a rate-limit punishment. */ +function busyResponse(locale: Locale): Response { + return Response.json( + { error: translate(locale, "server.busy"), kind: "busy", retryAfter: 5 }, + { status: 429, headers: { "Retry-After": "5", "Cache-Control": "no-store" } }, + ); +} diff --git a/lib/push-store.ts b/lib/push-store.ts index 1e9c749..7259c44 100644 --- a/lib/push-store.ts +++ b/lib/push-store.ts @@ -46,14 +46,19 @@ async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]): export async function addSubscription( dir: string, subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, -): Promise { + /** Cap on stored subscriptions — the configured, sane one. */ + max = 50, +): Promise<{ count: number; accepted: boolean }> { const current = await readSubscriptions(dir); + const exists = current.some((entry) => entry.endpoint === subscription.endpoint); const next = current.filter((entry) => entry.endpoint !== subscription.endpoint); + // Refreshing a device that is already subscribed always works; a brand new + // device over the cap is refused. + if (!exists && next.length >= max) return { count: current.length, accepted: false }; next.push({ ...subscription, createdAt: Date.now() }); - // A cap keeps a runaway script from filling the disk. - const capped = next.slice(-2000); + const capped = next.slice(-max); await writeSubscriptions(dir, capped); - return capped.length; + return { count: capped.length, accepted: true }; } export async function removeSubscription(dir: string, endpoint: string): Promise { diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 120206b..824c290 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -73,12 +73,53 @@ function prune(now: number, windowMs: number): void { } } +/* ------------------------------------------------------------------ */ +/* In-flight slots (concurrency, not per-window) */ +/* ------------------------------------------------------------------ */ + +/** + * Per-window limits alone let a client fire every allowed request at once: + * ten parallel model calls still hammer Ollama (and hold ten sockets). These + * counters cap what is running *right now*. Kept on globalThis for the same + * reason as the buckets. + */ +const slotStore = globalThis as unknown as { __freezmoSlots?: Map }; +const slots: Map = (slotStore.__freezmoSlots ??= new Map()); + +/** Take one of `max` in-flight slots for `key`. */ +export function acquireSlot(key: string, max: number): boolean { + const current = slots.get(key) ?? 0; + if (current >= max) return false; + slots.set(key, current + 1); + return true; +} + +/** Give an in-flight slot back. */ +export function releaseSlot(key: string): void { + const current = slots.get(key) ?? 0; + if (current <= 1) slots.delete(key); + else slots.set(key, current - 1); +} + +/** How many slots are in flight for a key (diagnostics and tests). */ +export function inFlight(key: string): number { + return slots.get(key) ?? 0; +} + /** Best-effort client identity: proxy headers first, then a shared bucket. */ export function clientKey(request: Request): string { const forwarded = request.headers.get("x-forwarded-for"); if (forwarded) { - const first = forwarded.split(",")[0]?.trim(); - if (first) return first; + // Take the **last** hop, not the first: our reverse proxy appends the peer + // it saw, so the last entry is the one connection we can trust. Reading the + // first entry would let a client send its own `x-forwarded-for` and dodge + // every per-IP limit. + const hops = forwarded + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + const last = hops.at(-1); + if (last) return last; } return ( request.headers.get("x-real-ip") ?? diff --git a/lib/schemas.ts b/lib/schemas.ts index 005c717..7f43aa8 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -8,6 +8,19 @@ import { z } from "zod"; export const difficultySchema = z.enum(["easy", "medium", "hard"]); export type Difficulty = z.infer; +/** + * Photos and rendered pages travel as base64 data URLs — never plain URLs. + * A URL here would make Ollama fetch an arbitrary address on our behalf (an + * SSRF hole through the model host), so the shape is part of the contract. + */ +export function dataUrlSchema(maxChars: number) { + return z + .string() + .min(32) + .max(maxChars) + .regex(/^data:[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*;base64,/i, "must be a base64 data URL"); +} + const baseActivity = { /** Short label shown in the feed chrome, e.g. "Cell biology". */ topic: z.string().min(1).max(120).optional(), diff --git a/next.config.ts b/next.config.ts index 0d9cebb..d5af961 100644 --- a/next.config.ts +++ b/next.config.ts @@ -23,6 +23,26 @@ const nextConfig: NextConfig = { * the e2e suites are unaffected. */ output: "standalone", + /** + * Security headers. The app has no accounts, so these are about containment: + * never framed (clickjacking), never sniffed into a script, and no camera, + * microphone or location access it does not need (the camera button uses a + * file input with `capture`, which is not gated by Permissions-Policy). + */ + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "Content-Security-Policy", value: "frame-ancestors 'none'" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=()" }, + ], + }, + ]; + }, allowedDevOrigins: [ "127.0.0.1", "localhost", diff --git a/package.json b/package.json index 5528d2e..7c12ce6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freezmo", - "version": "0.1.2", + "version": "0.1.3", "private": true, "license": "GPL-3.0-only", "scripts": { diff --git a/public/sw.js b/public/sw.js index 6f0c56f..bf3e14c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v50"; +const CACHE_VERSION = "freezmo-v51"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/phase2-test.mjs b/tests/phase2-test.mjs index 808b1a0..3edd76f 100644 --- a/tests/phase2-test.mjs +++ b/tests/phase2-test.mjs @@ -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 { diff --git a/tests/run-all.mjs b/tests/run-all.mjs index 8c8dcce..5961736 100644 --- a/tests/run-all.mjs +++ b/tests/run-all.mjs @@ -22,6 +22,7 @@ const SUITES = [ "phase1-test", "phase2-test", "phase3-test", + "security-test", "chat-test", "review-test", ]; diff --git a/tests/security-test.mjs b/tests/security-test.mjs new file mode 100644 index 0000000..fd55fe8 --- /dev/null +++ b/tests/security-test.mjs @@ -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();