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

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