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` |
| 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

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

View file

@ -742,6 +742,9 @@ const en: Record<string, string> = {
"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<string, string> = {
"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":

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(
dir: 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 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<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. */
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") ??

View file

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

View file

@ -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",

View file

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

View file

@ -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`;

View file

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

View file

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

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

@ -0,0 +1,208 @@
/**
* Security limits: the model gate (per-IP concurrency + the account-wide
* budget), the data-URL contract, the proxy-hop identity, the disk guards and
* the response headers.
*
* Runs against its own instance with a slow stub Ollama and deliberately tiny
* limits, so every guard is observable in a second or two.
*/
import http from "node:http";
import { mkdirSync, readFileSync, rmSync } from "node:fs";
import { launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
const { check, finish } = makeChecker();
const STUB_PORT = 11997;
const APP_PORT = 3210;
const STUB_BASE = `http://127.0.0.1:${STUB_PORT}`;
const APP = `http://127.0.0.1:${APP_PORT}`;
const REPORTS_DIR = new URL("./.tmp/security-reports", import.meta.url).pathname;
const PUSH_DIR = new URL("./.tmp/security-push", import.meta.url).pathname;
/* A deliberately slow stub: model calls stay in flight long enough to test
the concurrency gate, and the payload itself never matters here. */
const stub = http.createServer((request, response) => {
if (request.url?.startsWith("/api/tags")) {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ models: [{ name: "stub", model: "stub" }] }));
return;
}
request.on("data", () => {});
request.on("end", () => {
setTimeout(() => {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ message: { content: "{}" } }));
}, 700);
});
});
await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve));
rmSync(REPORTS_DIR, { recursive: true, force: true });
rmSync(PUSH_DIR, { recursive: true, force: true });
mkdirSync(REPORTS_DIR, { recursive: true });
mkdirSync(PUSH_DIR, { recursive: true });
check("security app port is free", !(await portInUse(APP_PORT)));
const app = spawnApp(APP_PORT, {
OLLAMA_BASE_URL: STUB_BASE,
OLLAMA_AUTH: "local",
OLLAMA_VERIFY_KEY: "false",
OLLAMA_TIMEOUT_MS: "10000",
RATE_LIMIT_CONCURRENT: "2",
RATE_LIMIT_CONCURRENT_TOTAL: "3",
RATE_LIMIT_MODEL_TOTAL: "6",
RATE_LIMIT_REPORTS: "1",
REPORTS_DIR,
REPORTS_MAX_BYTES: "900",
PUSH_DIR,
PUSH_MAX_SUBSCRIPTIONS: "2",
VAPID_PUBLIC_KEY: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
VAPID_PRIVATE_KEY: "UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls",
VAPID_SUBJECT: "mailto:test@example.com",
});
const browser = await launchBrowser();
try {
const up = await waitForHttp(`${APP}/api/health`);
check("security app instance is up", up);
if (up) {
/* 1. per-IP concurrency: only N model calls in flight ---------------- */
const generate = (body) =>
fetch(`${APP}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Photosynthesis needs light and water.", ...body }),
});
const parallel = await Promise.all([generate({}), generate({}), generate({})]);
const statuses = parallel.map((response) => response.status).sort();
check(
"concurrency: the third parallel model call is refused as busy",
statuses.filter((status) => status === 200).length === 2 && statuses.includes(429),
JSON.stringify(statuses),
);
const busy = await parallel.find((response) => response.status === 429)?.json();
check("concurrency: the refusal is a friendly busy 429", busy?.kind === "busy" && typeof busy?.error === "string", JSON.stringify(busy));
// Drain the two streams so their slots are released.
await Promise.all(parallel.map((response) => response.text().catch(() => "")));
/* 2. the account-wide budget: even sequential calls stop ------------
Two calls were admitted above (the refused one never counted), and the
data-URL call below adds one — so with a budget of six, three more get
through and the next is refused. */
const plainUrl = await fetch(`${APP}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ images: [`http://169.254.169.254/latest/meta-data/`, "x".repeat(40)] }),
});
check("data URLs: a plain URL is rejected", plainUrl.status === 400, String(plainUrl.status));
const dataUrl = await fetch(`${APP}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ images: [`data:image/jpeg;base64,${"A".repeat(200)}`] }),
});
check("data URLs: a real data URL is accepted", dataUrl.status === 200, String(dataUrl.status));
await dataUrl.text().catch(() => "");
const admitted = [];
for (let index = 0; index < 3; index += 1) {
const response = await generate({});
admitted.push(response.status);
await response.text().catch(() => "");
}
const overBudget = await generate({});
const overBudgetBody = overBudget.status === 429 ? await overBudget.json() : null;
check(
"budget: the model-call budget stops a flood",
admitted.every((status) => status === 200) && overBudget.status === 429 && overBudgetBody?.kind === "rate_limited",
`${JSON.stringify(admitted)} then ${overBudget.status} ${JSON.stringify(overBudgetBody)}`,
);
/* 4. the rate-limit identity is the last proxy hop ------------------ */
const report = (forwarded) =>
fetch(`${APP}/api/reports`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-forwarded-for": forwarded },
body: JSON.stringify({
deckId: "seed_deck",
activityId: "a1",
type: "multiple_choice",
question: "What colour is a clear daytime sky?",
at: Date.now(),
}),
});
const spoofOne = await report("9.9.9.9, 10.0.0.1");
const spoofTwo = await report("8.8.8.8, 10.0.0.1");
const other = await report("7.7.7.7, 10.0.0.2");
check(
"identity: a spoofed x-forwarded-for cannot dodge the per-IP limit",
spoofOne.status === 200 && spoofTwo.status === 429 && other.status === 200,
`${spoofOne.status}/${spoofTwo.status}/${other.status}`,
);
/* 5. the reports file stops growing at its cap ---------------------- */
for (let index = 0; index < 6; index += 1) {
await report(`7.7.7.7, 10.0.0.${10 + index}`);
}
const reportsSize = readFileSync(`${REPORTS_DIR}/reports.jsonl`, "utf8").length;
check("reports: the JSONL stops at its size cap", reportsSize > 0 && reportsSize < 2000, `${reportsSize} bytes`);
/* 6. push endpoints must be real push services ---------------------- */
const subscribe = (endpoint) =>
fetch(`${APP}/api/push`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint, keys: { p256dh: "B".repeat(40), auth: "C".repeat(20) } }),
});
const evil = await subscribe("https://evil.example/collect");
check("push: an arbitrary endpoint is rejected (no SSRF)", evil.status === 400, String(evil.status));
const good = await subscribe("https://fcm.googleapis.com/fcm/send/abc");
const goodSecond = await subscribe("https://updates.push.services.mozilla.com/wpush/v2/abc");
const overCap = await subscribe("https://web.push.apple.com/abc");
check(
"push: subscriptions are capped",
good.status === 200 && goodSecond.status === 200 && overCap.status === 429,
`${good.status}/${goodSecond.status}/${overCap.status}`,
);
/* 7. response headers ------------------------------------------------ */
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(`${APP}/`, { waitUntil: "domcontentloaded" });
const headers = await page.evaluate(async () => {
const response = await fetch("/", { cache: "no-store" });
return {
frame: response.headers.get("x-frame-options"),
csp: response.headers.get("content-security-policy"),
nosniff: response.headers.get("x-content-type-options"),
referrer: response.headers.get("referrer-policy"),
};
});
check(
"headers: the app refuses to be framed and sniffed",
headers.frame === "DENY" &&
(headers.csp ?? "").includes("frame-ancestors 'none'") &&
headers.nosniff === "nosniff" &&
headers.referrer === "strict-origin-when-cross-origin",
JSON.stringify(headers),
);
/* 8. cross-origin writes stay rejected ------------------------------- */
const crossOrigin = await fetch(`${APP}/api/reports`, {
method: "POST",
headers: { "Content-Type": "application/json", Origin: "https://evil.example" },
body: JSON.stringify({ deckId: "d", activityId: "a", type: "multiple_choice", question: "q", at: Date.now() }),
});
check("cross-origin POST is refused", crossOrigin.status === 403, String(crossOrigin.status));
await context.close();
}
} finally {
app.stop();
stub.close();
await browser.close();
}
finish();