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
44
README.md
44
README.md
|
|
@ -454,10 +454,31 @@ client IP with a sliding window. Everything is configurable in `.env.local`:
|
|||
| `GET/POST/DELETE /api/push`, `POST /api/push/test` | 30 per 5 minutes | `RATE_LIMIT_PUSH` |
|
||||
| window for all of them | 5 minutes | `RATE_LIMIT_WINDOW_MS` |
|
||||
|
||||
On top of the per-route windows there is one **account-level guard**, so no flood — from one client
|
||||
or from many — can get the Ollama key throttled or banned:
|
||||
|
||||
| Guard | Default | Env var |
|
||||
| --- | --- | --- |
|
||||
| all model calls together (generate, homework, refine, chat, extract, grade) | 60 per window | `RATE_LIMIT_MODEL_TOTAL` |
|
||||
| model calls in flight per client IP | 3 | `RATE_LIMIT_CONCURRENT` |
|
||||
| model calls in flight across all clients | 6 | `RATE_LIMIT_CONCURRENT_TOTAL` |
|
||||
|
||||
A request over the concurrency cap gets a **429 `kind: "busy"`** (“Freezmo is already working on
|
||||
something — try again in a moment”), not a punishment: the slot frees as soon as the model call
|
||||
finishes, and the client's Retry button works. Slots are taken *after* every cheap rejection, so a
|
||||
malformed request can never hold one.
|
||||
|
||||
Two more disk guards: `REPORTS_MAX_BYTES` (default 8 MB) stops appending to `reports.jsonl` once it
|
||||
is full, and `PUSH_MAX_SUBSCRIPTIONS` (default 50) refuses new reminder devices over the cap.
|
||||
Reminder endpoints must also be a real push service — `fcm.googleapis.com`,
|
||||
`updates.push.services.mozilla.com`, `*.push.apple.com`, `*.notify.windows.com` — or a host you add
|
||||
with `PUSH_ALLOWED_HOSTS`, because the server POSTs to whatever is stored.
|
||||
|
||||
When a limit is hit the route answers **429** with a `Retry-After` header and a friendly message
|
||||
(“Too many requests — try again in 42s”); the UI shows that message and keeps the Retry button. The
|
||||
client IP is taken from `x-forwarded-for` / `x-real-ip` when present (reverse proxy), otherwise all
|
||||
local clients share one bucket.
|
||||
client IP is taken from the **last** `x-forwarded-for` hop (the one your reverse proxy appended) or
|
||||
from `x-real-ip`; taking the first hop would let a client spoof its own identity and dodge every
|
||||
per-IP limit. Requests without those headers share one bucket.
|
||||
|
||||
Caveats, stated honestly: counters live **in memory**, so they reset on restart and are not shared
|
||||
between multiple instances or Node workers. That is fine for a self-hosted single-instance app; if
|
||||
|
|
@ -658,6 +679,19 @@ self-hosted study app with no accounts, no database and no server-side state.
|
|||
parsing, on top of the zod limits on image count, text length and field sizes.
|
||||
- **Rate limiting** per client IP; see [Rate limits](#rate-limits). Auth is checked *before* parsing
|
||||
request bodies, so unauthenticated traffic costs almost nothing.
|
||||
- **An account-level model budget and concurrency caps** sit in front of every route that spends an
|
||||
Ollama call (`lib/model-guard.ts`): 60 calls per window across all clients, 3 in flight per IP, 6
|
||||
in flight in total. A flood cannot pile up parallel calls or burn the account — the thing that
|
||||
gets a key throttled or banned. Slots are released when the call finishes, including a client that
|
||||
disconnects mid-stream.
|
||||
- **Images must be base64 data URLs** (`dataUrlSchema`). A plain URL in an `images` field would make
|
||||
Ollama fetch an arbitrary address on our behalf, so the shape is part of the contract.
|
||||
- **Reminder endpoints must be real push services** (an allowlist, `PUSH_ALLOWED_HOSTS`): the server
|
||||
POSTs to whatever is stored, so an arbitrary URL there would be an SSRF primitive.
|
||||
- **Security headers** on every response: `X-Frame-Options: DENY`, `Content-Security-Policy:
|
||||
frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, a referrer policy and a
|
||||
Permissions-Policy that turns off camera/microphone/geolocation/payment (the camera button is a
|
||||
file input with `capture`, which is not gated by that policy).
|
||||
- **No CSRF surface**: no cookies, no sessions, no server-side mutations.
|
||||
- **Prompt injection is low impact by design.** Your notes could contain text that tries to
|
||||
manipulate the model, but the model can only return a JSON deck (validated per activity, dropped if
|
||||
|
|
@ -670,8 +704,10 @@ self-hosted study app with no accounts, no database and no server-side state.
|
|||
notes.
|
||||
- Anything stored in `localStorage` is readable by JavaScript on the same origin. Freezmo has no XSS
|
||||
vector today, but that is the standard risk of browser-stored credentials.
|
||||
- Rate limiting trusts `x-forwarded-for` when present (correct behind a proxy, spoofable when
|
||||
exposed directly) and keeps counters in memory: single instance only, reset on restart.
|
||||
- Rate limiting keeps counters in memory: single instance only, reset on restart. The client IP is
|
||||
read from the **last** `x-forwarded-for` hop, so a client cannot spoof its identity — but behind
|
||||
more than one proxy the "last hop" is the proxy closest to Freezmo, which means those clients
|
||||
share one bucket. Point `x-real-ip` at the real client if you need per-client buckets there.
|
||||
- Ollama cloud usage is billed to whoever's key is used, so treat a shared Freezmo URL as shared key
|
||||
access in `server` mode.
|
||||
- No accounts means no per-user isolation: anyone who can reach the URL can use the app (and, in
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@
|
|||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||
import { acquireModelGate } from "@/lib/model-guard";
|
||||
import { dataUrlSchema } from "@/lib/schemas";
|
||||
import { translate } from "@/lib/i18n-dict";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
|
@ -16,7 +18,7 @@ const MAX_BODY_BYTES = 33554432;
|
|||
const MAX_IMAGE_CHARS = 8_000_000;
|
||||
|
||||
const requestSchema = z.object({
|
||||
images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(),
|
||||
images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
|
||||
text: z.string().max(120_000).optional(),
|
||||
});
|
||||
|
||||
|
|
@ -66,6 +68,8 @@ export async function POST(request: Request) {
|
|||
return Response.json({ error: translate(locale, "server.needContent") }, { status: 400 });
|
||||
}
|
||||
|
||||
const gate = acquireModelGate(request, config, locale);
|
||||
if ("response" in gate) return gate.response;
|
||||
const abort = new AbortController();
|
||||
const timer = setTimeout(() => abort.abort(), config.timeoutMs);
|
||||
try {
|
||||
|
|
@ -97,6 +101,7 @@ export async function POST(request: Request) {
|
|||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
gate.gate.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM
|
|||
import { friendlyErrorMessage, ollamaChatStream, OllamaError } from "@/lib/ollama";
|
||||
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||
import { acquireModelGate } from "@/lib/model-guard";
|
||||
import { translate } from "@/lib/i18n-dict";
|
||||
import { buildChatSystemPrompt } from "@/lib/prompts";
|
||||
import { estimateTokens, trimHistory } from "@/lib/chat-trim";
|
||||
|
|
@ -131,6 +132,11 @@ export async function POST(request: Request) {
|
|||
})),
|
||||
});
|
||||
|
||||
// Concurrency + the account-wide model budget (lib/model-guard), taken
|
||||
// after every cheap rejection so a 400 can never leak a slot.
|
||||
const gate = acquireModelGate(request, config, locale);
|
||||
if ("response" in gate) return gate.response;
|
||||
|
||||
const encode = encoder();
|
||||
const abort = new AbortController();
|
||||
const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs);
|
||||
|
|
@ -170,6 +176,7 @@ export async function POST(request: Request) {
|
|||
}
|
||||
} finally {
|
||||
clearTimeout(globalTimer);
|
||||
gate.gate.release();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
|
|
@ -179,6 +186,7 @@ export async function POST(request: Request) {
|
|||
},
|
||||
cancel() {
|
||||
clearTimeout(globalTimer);
|
||||
gate.gate.release();
|
||||
abort.abort();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM
|
|||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||
import { acquireModelGate } from "@/lib/model-guard";
|
||||
import { translate } from "@/lib/i18n-dict";
|
||||
import { difficultySchema } from "@/lib/schemas";
|
||||
import { dataUrlSchema, difficultySchema } from "@/lib/schemas";
|
||||
import { uid } from "@/lib/utils";
|
||||
import { xpForActivity } from "@/lib/xp";
|
||||
|
||||
|
|
@ -18,7 +19,7 @@ export const maxDuration = 300;
|
|||
const MAX_IMAGE_CHARS = 8_000_000;
|
||||
|
||||
const requestSchema = z.object({
|
||||
images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(),
|
||||
images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
|
||||
text: z.string().max(120_000).optional(),
|
||||
difficulty: difficultySchema.default("medium"),
|
||||
count: z.number().int().min(4).max(18).optional(),
|
||||
|
|
@ -101,6 +102,11 @@ export async function POST(request: Request) {
|
|||
);
|
||||
}
|
||||
|
||||
// Concurrency + the account-wide model budget (lib/model-guard). Taken
|
||||
// only now, after every cheap rejection, so a 400 can never leak a slot.
|
||||
const gate = acquireModelGate(request, config, locale);
|
||||
if ("response" in gate) return gate.response;
|
||||
|
||||
const requestedCount = count ?? config.defaultActivityCount;
|
||||
const encode = encoder();
|
||||
const abort = new AbortController();
|
||||
|
|
@ -201,6 +207,7 @@ export async function POST(request: Request) {
|
|||
});
|
||||
} finally {
|
||||
clearTimeout(globalTimer);
|
||||
gate.gate.release();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
|
|
@ -210,6 +217,7 @@ export async function POST(request: Request) {
|
|||
},
|
||||
cancel() {
|
||||
clearTimeout(globalTimer);
|
||||
gate.gate.release();
|
||||
abort.abort();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@
|
|||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||
import { acquireModelGate } from "@/lib/model-guard";
|
||||
import { translate } from "@/lib/i18n-dict";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
|
@ -54,6 +55,8 @@ export async function POST(request: Request) {
|
|||
return Response.json({ error: translate(locale, "server.invalidGrade") }, { status: 400 });
|
||||
}
|
||||
|
||||
const gate = acquireModelGate(request, config, locale);
|
||||
if ("response" in gate) return gate.response;
|
||||
try {
|
||||
const result = await gradeShortAnswer({
|
||||
model: config.textModel,
|
||||
|
|
@ -72,5 +75,7 @@ export async function POST(request: Request) {
|
|||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
} finally {
|
||||
gate.gate.release();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
|||
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||
import { translate } from "@/lib/i18n-dict";
|
||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||
import { acquireModelGate } from "@/lib/model-guard";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
@ -64,6 +65,8 @@ export async function POST(request: Request) {
|
|||
);
|
||||
}
|
||||
|
||||
const gate = acquireModelGate(request, config, locale);
|
||||
if ("response" in gate) return gate.response;
|
||||
try {
|
||||
const text = await refineHomeworkText({
|
||||
model: config.textModel,
|
||||
|
|
@ -86,5 +89,7 @@ export async function POST(request: Request) {
|
|||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
} finally {
|
||||
gate.gate.release();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM
|
|||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||
import { acquireModelGate } from "@/lib/model-guard";
|
||||
import { translate } from "@/lib/i18n-dict";
|
||||
import { homeworkModeSchema, type HomeworkStep } from "@/lib/schemas";
|
||||
import { dataUrlSchema, homeworkModeSchema, type HomeworkStep } from "@/lib/schemas";
|
||||
import { uid } from "@/lib/utils";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
|
@ -20,12 +21,12 @@ const MAX_BODY_BYTES = 33554432;
|
|||
|
||||
const requestSchema = z.object({
|
||||
mode: homeworkModeSchema,
|
||||
images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(),
|
||||
images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
|
||||
text: z.string().max(120_000).optional(),
|
||||
/** Mode "check": the student's own attempt at the assignment. */
|
||||
solution: z.string().max(120_000).optional(),
|
||||
/** Mode "check": photos of the attempt (handwritten work, scanned pages). */
|
||||
solutionImages: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(),
|
||||
solutionImages: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
|
||||
/** Language of the assignment (usually detected by the vision step). */
|
||||
language: z.string().max(60).optional(),
|
||||
});
|
||||
|
|
@ -95,6 +96,11 @@ export async function POST(request: Request) {
|
|||
return Response.json({ error: translate(locale, "server.needSolution") }, { status: 400 });
|
||||
}
|
||||
|
||||
// Concurrency + the account-wide model budget (lib/model-guard), taken
|
||||
// after every cheap rejection so a 400 can never leak a slot.
|
||||
const gate = acquireModelGate(request, config, locale);
|
||||
if ("response" in gate) return gate.response;
|
||||
|
||||
const encode = encoder();
|
||||
const abort = new AbortController();
|
||||
// A single hard deadline for the whole pipeline (two sequential model calls).
|
||||
|
|
@ -257,6 +263,7 @@ export async function POST(request: Request) {
|
|||
});
|
||||
} finally {
|
||||
clearTimeout(globalTimer);
|
||||
gate.gate.release();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
|
|
@ -266,6 +273,7 @@ export async function POST(request: Request) {
|
|||
},
|
||||
cancel() {
|
||||
clearTimeout(globalTimer);
|
||||
gate.gate.release();
|
||||
abort.abort();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,23 @@ export const dynamic = "force-dynamic";
|
|||
* 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({
|
||||
|
|
@ -96,9 +113,21 @@ export async function POST(request: Request) {
|
|||
{ 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);
|
||||
return Response.json({ ok: true, count }, { headers: { "Cache-Control": "no-store" } });
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,16 @@ export async function POST(request: Request) {
|
|||
const line = JSON.stringify({ ...parsed.data, receivedAt: new Date().toISOString() });
|
||||
try {
|
||||
await fs.mkdir(config.reports.dir, { recursive: true });
|
||||
await fs.appendFile(path.join(config.reports.dir, "reports.jsonl"), `${line}\n`, "utf8");
|
||||
const file = path.join(config.reports.dir, "reports.jsonl");
|
||||
/* Disk guard: reports are a convenience for the operator, so once the
|
||||
file passes the cap the app stops appending (the browser's own list is
|
||||
still the source of truth for skipping). */
|
||||
const size = await fs.stat(file).then((stats) => stats.size).catch(() => 0);
|
||||
if (size >= config.reports.maxBytes) {
|
||||
console.warn("[api/reports] reports.jsonl is at its size cap — not appending");
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
await fs.appendFile(file, `${line}\n`, "utf8");
|
||||
} catch {
|
||||
return Response.json({ error: "could not store the report" }, { status: 500 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,26 @@ const nextConfig: NextConfig = {
|
|||
* the e2e suites are unaffected.
|
||||
*/
|
||||
output: "standalone",
|
||||
/**
|
||||
* Security headers. The app has no accounts, so these are about containment:
|
||||
* never framed (clickjacking), never sniffed into a script, and no camera,
|
||||
* microphone or location access it does not need (the camera button uses a
|
||||
* file input with `capture`, which is not gated by Permissions-Policy).
|
||||
*/
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Content-Security-Policy", value: "frame-ancestors 'none'" },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=()" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
allowedDevOrigins: [
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "freezmo",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v50";
|
||||
const CACHE_VERSION = "freezmo-v51";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
|
|
@ -447,6 +447,8 @@ const browser = await launchBrowser();
|
|||
VAPID_PRIVATE_KEY: vapid.privateKey,
|
||||
VAPID_SUBJECT: "mailto:test@example.com",
|
||||
PUSH_DIR: path.join(REPO, "tests", ".tmp", "push"),
|
||||
// The endpoint in this suite is a fake host: allow it explicitly.
|
||||
PUSH_ALLOWED_HOSTS: "push.example.invalid",
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const SUITES = [
|
|||
"phase1-test",
|
||||
"phase2-test",
|
||||
"phase3-test",
|
||||
"security-test",
|
||||
"chat-test",
|
||||
"review-test",
|
||||
];
|
||||
|
|
|
|||
208
tests/security-test.mjs
Normal file
208
tests/security-test.mjs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* Security limits: the model gate (per-IP concurrency + the account-wide
|
||||
* budget), the data-URL contract, the proxy-hop identity, the disk guards and
|
||||
* the response headers.
|
||||
*
|
||||
* Runs against its own instance with a slow stub Ollama and deliberately tiny
|
||||
* limits, so every guard is observable in a second or two.
|
||||
*/
|
||||
import http from "node:http";
|
||||
import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
|
||||
import { launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
|
||||
|
||||
const { check, finish } = makeChecker();
|
||||
|
||||
const STUB_PORT = 11997;
|
||||
const APP_PORT = 3210;
|
||||
const STUB_BASE = `http://127.0.0.1:${STUB_PORT}`;
|
||||
const APP = `http://127.0.0.1:${APP_PORT}`;
|
||||
|
||||
const REPORTS_DIR = new URL("./.tmp/security-reports", import.meta.url).pathname;
|
||||
const PUSH_DIR = new URL("./.tmp/security-push", import.meta.url).pathname;
|
||||
|
||||
/* A deliberately slow stub: model calls stay in flight long enough to test
|
||||
the concurrency gate, and the payload itself never matters here. */
|
||||
const stub = http.createServer((request, response) => {
|
||||
if (request.url?.startsWith("/api/tags")) {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ models: [{ name: "stub", model: "stub" }] }));
|
||||
return;
|
||||
}
|
||||
request.on("data", () => {});
|
||||
request.on("end", () => {
|
||||
setTimeout(() => {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ message: { content: "{}" } }));
|
||||
}, 700);
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve));
|
||||
|
||||
rmSync(REPORTS_DIR, { recursive: true, force: true });
|
||||
rmSync(PUSH_DIR, { recursive: true, force: true });
|
||||
mkdirSync(REPORTS_DIR, { recursive: true });
|
||||
mkdirSync(PUSH_DIR, { recursive: true });
|
||||
|
||||
check("security app port is free", !(await portInUse(APP_PORT)));
|
||||
const app = spawnApp(APP_PORT, {
|
||||
OLLAMA_BASE_URL: STUB_BASE,
|
||||
OLLAMA_AUTH: "local",
|
||||
OLLAMA_VERIFY_KEY: "false",
|
||||
OLLAMA_TIMEOUT_MS: "10000",
|
||||
RATE_LIMIT_CONCURRENT: "2",
|
||||
RATE_LIMIT_CONCURRENT_TOTAL: "3",
|
||||
RATE_LIMIT_MODEL_TOTAL: "6",
|
||||
RATE_LIMIT_REPORTS: "1",
|
||||
REPORTS_DIR,
|
||||
REPORTS_MAX_BYTES: "900",
|
||||
PUSH_DIR,
|
||||
PUSH_MAX_SUBSCRIPTIONS: "2",
|
||||
VAPID_PUBLIC_KEY: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
|
||||
VAPID_PRIVATE_KEY: "UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls",
|
||||
VAPID_SUBJECT: "mailto:test@example.com",
|
||||
});
|
||||
|
||||
const browser = await launchBrowser();
|
||||
|
||||
try {
|
||||
const up = await waitForHttp(`${APP}/api/health`);
|
||||
check("security app instance is up", up);
|
||||
|
||||
if (up) {
|
||||
/* 1. per-IP concurrency: only N model calls in flight ---------------- */
|
||||
const generate = (body) =>
|
||||
fetch(`${APP}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: "Photosynthesis needs light and water.", ...body }),
|
||||
});
|
||||
const parallel = await Promise.all([generate({}), generate({}), generate({})]);
|
||||
const statuses = parallel.map((response) => response.status).sort();
|
||||
check(
|
||||
"concurrency: the third parallel model call is refused as busy",
|
||||
statuses.filter((status) => status === 200).length === 2 && statuses.includes(429),
|
||||
JSON.stringify(statuses),
|
||||
);
|
||||
const busy = await parallel.find((response) => response.status === 429)?.json();
|
||||
check("concurrency: the refusal is a friendly busy 429", busy?.kind === "busy" && typeof busy?.error === "string", JSON.stringify(busy));
|
||||
// Drain the two streams so their slots are released.
|
||||
await Promise.all(parallel.map((response) => response.text().catch(() => "")));
|
||||
|
||||
/* 2. the account-wide budget: even sequential calls stop ------------
|
||||
Two calls were admitted above (the refused one never counted), and the
|
||||
data-URL call below adds one — so with a budget of six, three more get
|
||||
through and the next is refused. */
|
||||
const plainUrl = await fetch(`${APP}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ images: [`http://169.254.169.254/latest/meta-data/`, "x".repeat(40)] }),
|
||||
});
|
||||
check("data URLs: a plain URL is rejected", plainUrl.status === 400, String(plainUrl.status));
|
||||
const dataUrl = await fetch(`${APP}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ images: [`data:image/jpeg;base64,${"A".repeat(200)}`] }),
|
||||
});
|
||||
check("data URLs: a real data URL is accepted", dataUrl.status === 200, String(dataUrl.status));
|
||||
await dataUrl.text().catch(() => "");
|
||||
|
||||
const admitted = [];
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const response = await generate({});
|
||||
admitted.push(response.status);
|
||||
await response.text().catch(() => "");
|
||||
}
|
||||
const overBudget = await generate({});
|
||||
const overBudgetBody = overBudget.status === 429 ? await overBudget.json() : null;
|
||||
check(
|
||||
"budget: the model-call budget stops a flood",
|
||||
admitted.every((status) => status === 200) && overBudget.status === 429 && overBudgetBody?.kind === "rate_limited",
|
||||
`${JSON.stringify(admitted)} then ${overBudget.status} ${JSON.stringify(overBudgetBody)}`,
|
||||
);
|
||||
|
||||
/* 4. the rate-limit identity is the last proxy hop ------------------ */
|
||||
const report = (forwarded) =>
|
||||
fetch(`${APP}/api/reports`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-forwarded-for": forwarded },
|
||||
body: JSON.stringify({
|
||||
deckId: "seed_deck",
|
||||
activityId: "a1",
|
||||
type: "multiple_choice",
|
||||
question: "What colour is a clear daytime sky?",
|
||||
at: Date.now(),
|
||||
}),
|
||||
});
|
||||
const spoofOne = await report("9.9.9.9, 10.0.0.1");
|
||||
const spoofTwo = await report("8.8.8.8, 10.0.0.1");
|
||||
const other = await report("7.7.7.7, 10.0.0.2");
|
||||
check(
|
||||
"identity: a spoofed x-forwarded-for cannot dodge the per-IP limit",
|
||||
spoofOne.status === 200 && spoofTwo.status === 429 && other.status === 200,
|
||||
`${spoofOne.status}/${spoofTwo.status}/${other.status}`,
|
||||
);
|
||||
|
||||
/* 5. the reports file stops growing at its cap ---------------------- */
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await report(`7.7.7.7, 10.0.0.${10 + index}`);
|
||||
}
|
||||
const reportsSize = readFileSync(`${REPORTS_DIR}/reports.jsonl`, "utf8").length;
|
||||
check("reports: the JSONL stops at its size cap", reportsSize > 0 && reportsSize < 2000, `${reportsSize} bytes`);
|
||||
|
||||
/* 6. push endpoints must be real push services ---------------------- */
|
||||
const subscribe = (endpoint) =>
|
||||
fetch(`${APP}/api/push`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint, keys: { p256dh: "B".repeat(40), auth: "C".repeat(20) } }),
|
||||
});
|
||||
const evil = await subscribe("https://evil.example/collect");
|
||||
check("push: an arbitrary endpoint is rejected (no SSRF)", evil.status === 400, String(evil.status));
|
||||
const good = await subscribe("https://fcm.googleapis.com/fcm/send/abc");
|
||||
const goodSecond = await subscribe("https://updates.push.services.mozilla.com/wpush/v2/abc");
|
||||
const overCap = await subscribe("https://web.push.apple.com/abc");
|
||||
check(
|
||||
"push: subscriptions are capped",
|
||||
good.status === 200 && goodSecond.status === 200 && overCap.status === 429,
|
||||
`${good.status}/${goodSecond.status}/${overCap.status}`,
|
||||
);
|
||||
|
||||
/* 7. response headers ------------------------------------------------ */
|
||||
const { context, page } = await makePage(browser);
|
||||
await seed(page, { decks: [] });
|
||||
await page.goto(`${APP}/`, { waitUntil: "domcontentloaded" });
|
||||
const headers = await page.evaluate(async () => {
|
||||
const response = await fetch("/", { cache: "no-store" });
|
||||
return {
|
||||
frame: response.headers.get("x-frame-options"),
|
||||
csp: response.headers.get("content-security-policy"),
|
||||
nosniff: response.headers.get("x-content-type-options"),
|
||||
referrer: response.headers.get("referrer-policy"),
|
||||
};
|
||||
});
|
||||
check(
|
||||
"headers: the app refuses to be framed and sniffed",
|
||||
headers.frame === "DENY" &&
|
||||
(headers.csp ?? "").includes("frame-ancestors 'none'") &&
|
||||
headers.nosniff === "nosniff" &&
|
||||
headers.referrer === "strict-origin-when-cross-origin",
|
||||
JSON.stringify(headers),
|
||||
);
|
||||
|
||||
/* 8. cross-origin writes stay rejected ------------------------------- */
|
||||
const crossOrigin = await fetch(`${APP}/api/reports`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Origin: "https://evil.example" },
|
||||
body: JSON.stringify({ deckId: "d", activityId: "a", type: "multiple_choice", question: "q", at: Date.now() }),
|
||||
});
|
||||
check("cross-origin POST is refused", crossOrigin.status === 403, String(crossOrigin.status));
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
app.stop();
|
||||
stub.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
finish();
|
||||
Loading…
Add table
Add a link
Reference in a new issue