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.
160 lines
5.4 KiB
TypeScript
160 lines
5.4 KiB
TypeScript
import { z } from "zod";
|
|
|
|
import { authErrorKind, authErrorMessage, getServerConfig, resolveAuth } from "@/lib/config";
|
|
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
|
import { translate } from "@/lib/i18n-dict";
|
|
import { addSubscription, removeSubscription } from "@/lib/push-store";
|
|
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
|
|
|
export const runtime = "nodejs";
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/**
|
|
* Push reminder subscriptions. GET reports whether reminders are available
|
|
* (VAPID configured) and hands out the public key; POST/DELETE manage this
|
|
* browser's subscription. The feature is hidden client-side when `enabled`
|
|
* 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({
|
|
p256dh: z.string().min(16).max(400),
|
|
auth: z.string().min(8).max(200),
|
|
}),
|
|
});
|
|
|
|
export async function GET(request: Request) {
|
|
const locale = requestLocale(request);
|
|
const crossOrigin = rejectCrossOrigin(request);
|
|
if (crossOrigin) return crossOrigin;
|
|
|
|
const config = getServerConfig();
|
|
const limit = rateLimit(`push:${clientKey(request)}`, config.rateLimits.push, config.rateLimits.windowMs);
|
|
if (!limit.allowed) return rateLimitResponse(limit, locale);
|
|
|
|
const auth = resolveAuth(config, request);
|
|
if ("error" in auth) {
|
|
return Response.json(
|
|
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) },
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
const enabled = Boolean(config.push.publicKey && config.push.privateKey);
|
|
return Response.json(
|
|
{ enabled, publicKey: enabled ? config.push.publicKey : undefined },
|
|
{ headers: { "Cache-Control": "no-store" } },
|
|
);
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const locale = requestLocale(request);
|
|
const crossOrigin = rejectCrossOrigin(request);
|
|
if (crossOrigin) return crossOrigin;
|
|
|
|
const config = getServerConfig();
|
|
const oversized = rejectOversizedBody(request, 64 * 1024);
|
|
if (oversized) return oversized;
|
|
|
|
const limit = rateLimit(`push:${clientKey(request)}`, config.rateLimits.push, config.rateLimits.windowMs);
|
|
if (!limit.allowed) return rateLimitResponse(limit, locale);
|
|
|
|
const auth = resolveAuth(config, request);
|
|
if ("error" in auth) {
|
|
return Response.json(
|
|
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) },
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
if (!config.push.publicKey || !config.push.privateKey) {
|
|
return Response.json(
|
|
{ error: translate(locale, "server.pushDisabled"), kind: "push_disabled" },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
|
|
let payload: unknown;
|
|
try {
|
|
payload = await request.json();
|
|
} catch {
|
|
return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 });
|
|
}
|
|
|
|
const parsed = subscriptionSchema.safeParse(payload);
|
|
if (!parsed.success) {
|
|
return Response.json(
|
|
{
|
|
error: translate(locale, "server.invalidRequest", {
|
|
detail: parsed.error.issues
|
|
.slice(0, 3)
|
|
.map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`)
|
|
.join("; "),
|
|
}),
|
|
},
|
|
{ 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, 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) {
|
|
const locale = requestLocale(request);
|
|
const crossOrigin = rejectCrossOrigin(request);
|
|
if (crossOrigin) return crossOrigin;
|
|
|
|
const config = getServerConfig();
|
|
const limit = rateLimit(`push:${clientKey(request)}`, config.rateLimits.push, config.rateLimits.windowMs);
|
|
if (!limit.allowed) return rateLimitResponse(limit, locale);
|
|
|
|
const auth = resolveAuth(config, request);
|
|
if ("error" in auth) {
|
|
return Response.json(
|
|
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) },
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
const endpoint = new URL(request.url).searchParams.get("endpoint") ?? "";
|
|
if (!endpoint) {
|
|
return Response.json(
|
|
{ error: translate(locale, "server.invalidRequest", { detail: "endpoint" }) },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const count = await removeSubscription(config.push.dir, endpoint);
|
|
return Response.json({ ok: true, count }, { headers: { "Cache-Control": "no-store" } });
|
|
}
|