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.
69 lines
2.6 KiB
TypeScript
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;
|
|
}
|