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

@ -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` | | `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` | | 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 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 (“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 client IP is taken from the **last** `x-forwarded-for` hop (the one your reverse proxy appended) or
local clients share one bucket. 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 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 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. 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 - **Rate limiting** per client IP; see [Rate limits](#rate-limits). Auth is checked *before* parsing
request bodies, so unauthenticated traffic costs almost nothing. 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. - **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 - **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 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. notes.
- Anything stored in `localStorage` is readable by JavaScript on the same origin. Freezmo has no XSS - 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. 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 - Rate limiting keeps counters in memory: single instance only, reset on restart. The client IP is
exposed directly) and keeps counters in memory: single instance only, reset on restart. 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 - Ollama cloud usage is billed to whoever's key is used, so treat a shared Freezmo URL as shared key
access in `server` mode. access in `server` mode.
- No accounts means no per-user isolation: anyone who can reach the URL can use the app (and, in - No accounts means no per-user isolation: anyone who can reach the URL can use the app (and, in

View file

@ -5,6 +5,8 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; 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"; import { translate } from "@/lib/i18n-dict";
export const runtime = "nodejs"; export const runtime = "nodejs";
@ -16,7 +18,7 @@ const MAX_BODY_BYTES = 33554432;
const MAX_IMAGE_CHARS = 8_000_000; const MAX_IMAGE_CHARS = 8_000_000;
const requestSchema = z.object({ 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(), 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 }); 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 abort = new AbortController();
const timer = setTimeout(() => abort.abort(), config.timeoutMs); const timer = setTimeout(() => abort.abort(), config.timeoutMs);
try { try {
@ -97,6 +101,7 @@ export async function POST(request: Request) {
); );
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
gate.gate.release();
} }
} }

View file

@ -4,6 +4,7 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM
import { friendlyErrorMessage, ollamaChatStream, OllamaError } from "@/lib/ollama"; import { friendlyErrorMessage, ollamaChatStream, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard";
import { translate } from "@/lib/i18n-dict"; import { translate } from "@/lib/i18n-dict";
import { buildChatSystemPrompt } from "@/lib/prompts"; import { buildChatSystemPrompt } from "@/lib/prompts";
import { estimateTokens, trimHistory } from "@/lib/chat-trim"; 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 encode = encoder();
const abort = new AbortController(); const abort = new AbortController();
const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs); const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs);
@ -170,6 +176,7 @@ export async function POST(request: Request) {
} }
} finally { } finally {
clearTimeout(globalTimer); clearTimeout(globalTimer);
gate.gate.release();
try { try {
controller.close(); controller.close();
} catch { } catch {
@ -179,6 +186,7 @@ export async function POST(request: Request) {
}, },
cancel() { cancel() {
clearTimeout(globalTimer); clearTimeout(globalTimer);
gate.gate.release();
abort.abort(); abort.abort();
}, },
}); });

View file

@ -5,8 +5,9 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard";
import { translate } from "@/lib/i18n-dict"; import { translate } from "@/lib/i18n-dict";
import { difficultySchema } from "@/lib/schemas"; import { dataUrlSchema, difficultySchema } from "@/lib/schemas";
import { uid } from "@/lib/utils"; import { uid } from "@/lib/utils";
import { xpForActivity } from "@/lib/xp"; import { xpForActivity } from "@/lib/xp";
@ -18,7 +19,7 @@ export const maxDuration = 300;
const MAX_IMAGE_CHARS = 8_000_000; const MAX_IMAGE_CHARS = 8_000_000;
const requestSchema = z.object({ 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(), text: z.string().max(120_000).optional(),
difficulty: difficultySchema.default("medium"), difficulty: difficultySchema.default("medium"),
count: z.number().int().min(4).max(18).optional(), 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 requestedCount = count ?? config.defaultActivityCount;
const encode = encoder(); const encode = encoder();
const abort = new AbortController(); const abort = new AbortController();
@ -201,6 +207,7 @@ export async function POST(request: Request) {
}); });
} finally { } finally {
clearTimeout(globalTimer); clearTimeout(globalTimer);
gate.gate.release();
try { try {
controller.close(); controller.close();
} catch { } catch {
@ -210,6 +217,7 @@ export async function POST(request: Request) {
}, },
cancel() { cancel() {
clearTimeout(globalTimer); clearTimeout(globalTimer);
gate.gate.release();
abort.abort(); abort.abort();
}, },
}); });

View file

@ -5,6 +5,7 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard";
import { translate } from "@/lib/i18n-dict"; import { translate } from "@/lib/i18n-dict";
export const runtime = "nodejs"; export const runtime = "nodejs";
@ -54,6 +55,8 @@ export async function POST(request: Request) {
return Response.json({ error: translate(locale, "server.invalidGrade") }, { status: 400 }); return Response.json({ error: translate(locale, "server.invalidGrade") }, { status: 400 });
} }
const gate = acquireModelGate(request, config, locale);
if ("response" in gate) return gate.response;
try { try {
const result = await gradeShortAnswer({ const result = await gradeShortAnswer({
model: config.textModel, model: config.textModel,
@ -72,5 +75,7 @@ export async function POST(request: Request) {
}, },
{ status: 502 }, { status: 502 },
); );
} finally {
gate.gate.release();
} }
} }

View file

@ -6,6 +6,7 @@ import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { translate } from "@/lib/i18n-dict"; import { translate } from "@/lib/i18n-dict";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; 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 { try {
const text = await refineHomeworkText({ const text = await refineHomeworkText({
model: config.textModel, model: config.textModel,
@ -86,5 +89,7 @@ export async function POST(request: Request) {
}, },
{ status: 502 }, { status: 502 },
); );
} finally {
gate.gate.release();
} }
} }

View file

@ -5,8 +5,9 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard";
import { translate } from "@/lib/i18n-dict"; 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"; import { uid } from "@/lib/utils";
export const runtime = "nodejs"; export const runtime = "nodejs";
@ -20,12 +21,12 @@ const MAX_BODY_BYTES = 33554432;
const requestSchema = z.object({ const requestSchema = z.object({
mode: homeworkModeSchema, 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(), text: z.string().max(120_000).optional(),
/** Mode "check": the student's own attempt at the assignment. */ /** Mode "check": the student's own attempt at the assignment. */
solution: z.string().max(120_000).optional(), solution: z.string().max(120_000).optional(),
/** Mode "check": photos of the attempt (handwritten work, scanned pages). */ /** 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 of the assignment (usually detected by the vision step). */
language: z.string().max(60).optional(), 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 }); 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 encode = encoder();
const abort = new AbortController(); const abort = new AbortController();
// A single hard deadline for the whole pipeline (two sequential model calls). // A single hard deadline for the whole pipeline (two sequential model calls).
@ -257,6 +263,7 @@ export async function POST(request: Request) {
}); });
} finally { } finally {
clearTimeout(globalTimer); clearTimeout(globalTimer);
gate.gate.release();
try { try {
controller.close(); controller.close();
} catch { } catch {
@ -266,6 +273,7 @@ export async function POST(request: Request) {
}, },
cancel() { cancel() {
clearTimeout(globalTimer); clearTimeout(globalTimer);
gate.gate.release();
abort.abort(); abort.abort();
}, },
}); });

View file

@ -16,6 +16,23 @@ export const dynamic = "force-dynamic";
* is false — no half-configured UI ever shows. * 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({ const subscriptionSchema = z.object({
endpoint: z.string().url().max(2000), endpoint: z.string().url().max(2000),
keys: z.object({ keys: z.object({
@ -96,9 +113,21 @@ export async function POST(request: Request) {
{ status: 400 }, { 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); const count = await addSubscription(config.push.dir, parsed.data, config.push.maxSubscriptions);
return Response.json({ ok: true, count }, { headers: { "Cache-Control": "no-store" } }); 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) { export async function DELETE(request: Request) {

View file

@ -51,7 +51,16 @@ export async function POST(request: Request) {
const line = JSON.stringify({ ...parsed.data, receivedAt: new Date().toISOString() }); const line = JSON.stringify({ ...parsed.data, receivedAt: new Date().toISOString() });
try { try {
await fs.mkdir(config.reports.dir, { recursive: true }); 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 { } catch {
return Response.json({ error: "could not store the report" }, { status: 500 }); return Response.json({ error: "could not store the report" }, { status: 500 });
} }

View file

@ -37,6 +37,12 @@ export type ServerConfig = {
push: number; push: number;
chat: number; chat: number;
reports: 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: { sync: {
/** Where opaque encrypted sync blobs live (SYNC_DIR). */ /** Where opaque encrypted sync blobs live (SYNC_DIR). */
@ -53,10 +59,20 @@ export type ServerConfig = {
subject: string; subject: string;
/** Where push subscriptions live (PUSH_DIR). */ /** Where push subscriptions live (PUSH_DIR). */
dir: string; 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: { reports: {
/** Where reported questions are appended, one JSON line each (REPORTS_DIR). */ /** Where reported questions are appended, one JSON line each (REPORTS_DIR). */
dir: string; 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), chat: positiveInt(process.env.RATE_LIMIT_CHAT, 60),
// Question reports are cheap file appends. // Question reports are cheap file appends.
reports: positiveInt(process.env.RATE_LIMIT_REPORTS, 20), 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: { sync: {
dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "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() || "", privateKey: process.env.VAPID_PRIVATE_KEY?.trim() || "",
subject: process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost", subject: process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost",
dir: process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push"), 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: { reports: {
dir: process.env.REPORTS_DIR?.trim() || path.join(process.cwd(), ".freezmo", "reports"), dir: process.env.REPORTS_DIR?.trim() || path.join(process.cwd(), ".freezmo", "reports"),
maxBytes: positiveInt(process.env.REPORTS_MAX_BYTES, 8 * 1024 * 1024),
}, },
}; };
} }

View file

@ -742,6 +742,9 @@ const en: Record<string, string> = {
"server.invalidOrigin": "Invalid Origin header.", "server.invalidOrigin": "Invalid Origin header.",
"server.crossOrigin": "Cross-origin requests are not allowed.", "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.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": "server.authMissingKey":
"Add your Ollama API key first — open the settings dialog (⚙️) and paste a key from ollama.com/settings/keys.", "Add your Ollama API key first — open the settings dialog (⚙️) and paste a key from ollama.com/settings/keys.",
"server.authServerKeyMissing": "server.authServerKeyMissing":
@ -1681,6 +1684,9 @@ const cs: Record<string, string> = {
"server.invalidOrigin": "Neplatná hlavička Origin.", "server.invalidOrigin": "Neplatná hlavička Origin.",
"server.crossOrigin": "Požadavky z jiné domény nejsou povoleny.", "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.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": "server.authMissingKey":
"Nejdřív přidej svůj API klíč Ollama — otevři nastavení (⚙️) a vlož klíč z ollama.com/settings/keys.", "Nejdřív přidej svůj API klíč Ollama — otevři nastavení (⚙️) a vlož klíč z ollama.com/settings/keys.",
"server.authServerKeyMissing": "server.authServerKeyMissing":

60
lib/model-guard.ts Normal file
View file

@ -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" } },
);
}

View file

@ -46,14 +46,19 @@ async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]):
export async function addSubscription( export async function addSubscription(
dir: string, dir: string,
subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
): Promise<number> { /** Cap on stored subscriptions — the configured, sane one. */
max = 50,
): Promise<{ count: number; accepted: boolean }> {
const current = await readSubscriptions(dir); const current = await readSubscriptions(dir);
const exists = current.some((entry) => entry.endpoint === subscription.endpoint);
const next = current.filter((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() }); next.push({ ...subscription, createdAt: Date.now() });
// A cap keeps a runaway script from filling the disk. const capped = next.slice(-max);
const capped = next.slice(-2000);
await writeSubscriptions(dir, capped); await writeSubscriptions(dir, capped);
return capped.length; return { count: capped.length, accepted: true };
} }
export async function removeSubscription(dir: string, endpoint: string): Promise<number> { export async function removeSubscription(dir: string, endpoint: string): Promise<number> {

View file

@ -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<string, number> };
const slots: Map<string, number> = (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. */ /** Best-effort client identity: proxy headers first, then a shared bucket. */
export function clientKey(request: Request): string { export function clientKey(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for"); const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) { if (forwarded) {
const first = forwarded.split(",")[0]?.trim(); // Take the **last** hop, not the first: our reverse proxy appends the peer
if (first) return first; // 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 ( return (
request.headers.get("x-real-ip") ?? request.headers.get("x-real-ip") ??

View file

@ -8,6 +8,19 @@ import { z } from "zod";
export const difficultySchema = z.enum(["easy", "medium", "hard"]); export const difficultySchema = z.enum(["easy", "medium", "hard"]);
export type Difficulty = z.infer<typeof difficultySchema>; export type Difficulty = z.infer<typeof difficultySchema>;
/**
* 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 = { const baseActivity = {
/** Short label shown in the feed chrome, e.g. "Cell biology". */ /** Short label shown in the feed chrome, e.g. "Cell biology". */
topic: z.string().min(1).max(120).optional(), topic: z.string().min(1).max(120).optional(),

View file

@ -23,6 +23,26 @@ const nextConfig: NextConfig = {
* the e2e suites are unaffected. * the e2e suites are unaffected.
*/ */
output: "standalone", 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: [ allowedDevOrigins: [
"127.0.0.1", "127.0.0.1",
"localhost", "localhost",

View file

@ -1,6 +1,6 @@
{ {
"name": "freezmo", "name": "freezmo",
"version": "0.1.2", "version": "0.1.3",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {

View file

@ -9,7 +9,7 @@
* *
* Bumping CACHE_VERSION is how a new deploy invalidates old assets. * 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -447,6 +447,8 @@ const browser = await launchBrowser();
VAPID_PRIVATE_KEY: vapid.privateKey, VAPID_PRIVATE_KEY: vapid.privateKey,
VAPID_SUBJECT: "mailto:test@example.com", VAPID_SUBJECT: "mailto:test@example.com",
PUSH_DIR: path.join(REPO, "tests", ".tmp", "push"), 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 { try {

View file

@ -22,6 +22,7 @@ const SUITES = [
"phase1-test", "phase1-test",
"phase2-test", "phase2-test",
"phase3-test", "phase3-test",
"security-test",
"chat-test", "chat-test",
"review-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();