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:
parent
aac6befb0d
commit
8822baf4b7
21 changed files with 528 additions and 21 deletions
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
60
lib/model-guard.ts
Normal 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" } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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") ??
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue