freezmo/lib/push-store.ts
Martin Dvorak 8822baf4b7 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.
2026-10-12 20:40:57 +00:00

69 lines
2.6 KiB
TypeScript

import { promises as fs } from "node:fs";
import path from "node:path";
/**
* Server-side push subscriptions: one JSON file with endpoint + keys and a
* creation timestamp. No personal data, no history, no user ids — and the
* whole feature stays hidden unless VAPID keys are configured.
*/
export type PushSubscriptionRecord = {
endpoint: string;
keys: { p256dh: string; auth: string };
createdAt: number;
};
function fileFor(dir: string): string {
return path.join(dir, "subscriptions.json");
}
export async function readSubscriptions(dir: string): Promise<PushSubscriptionRecord[]> {
try {
const raw = await fs.readFile(fileFor(dir), "utf8");
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(entry): entry is PushSubscriptionRecord =>
Boolean(entry) &&
typeof (entry as PushSubscriptionRecord).endpoint === "string" &&
typeof (entry as PushSubscriptionRecord).keys?.p256dh === "string" &&
typeof (entry as PushSubscriptionRecord).keys?.auth === "string",
);
} catch {
return [];
}
}
async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]): Promise<void> {
await fs.mkdir(dir, { recursive: true });
const file = fileFor(dir);
const temporary = `${file}.${crypto.randomUUID()}.tmp`;
await fs.writeFile(temporary, JSON.stringify(next, null, 2), "utf8");
await fs.rename(temporary, file);
}
/** Adds or refreshes a subscription (deduped by endpoint). */
export async function addSubscription(
dir: string,
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
/** 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() });
const capped = next.slice(-max);
await writeSubscriptions(dir, capped);
return { count: capped.length, accepted: true };
}
export async function removeSubscription(dir: string, endpoint: string): Promise<number> {
const current = await readSubscriptions(dir);
const next = current.filter((entry) => entry.endpoint !== endpoint);
await writeSubscriptions(dir, next);
return next.length;
}