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` |
|
| `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` |
|
| 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
|
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
|
(“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
|
client IP is taken from the **last** `x-forwarded-for` hop (the one your reverse proxy appended) or
|
||||||
local clients share one bucket.
|
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
|
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
|
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.
|
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
|
- **Rate limiting** per client IP; see [Rate limits](#rate-limits). Auth is checked *before* parsing
|
||||||
request bodies, so unauthenticated traffic costs almost nothing.
|
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.
|
- **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
|
- **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
|
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.
|
notes.
|
||||||
- Anything stored in `localStorage` is readable by JavaScript on the same origin. Freezmo has no XSS
|
- 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.
|
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
|
- Rate limiting keeps counters in memory: single instance only, reset on restart. The client IP is
|
||||||
exposed directly) and keeps counters in memory: single instance only, reset on restart.
|
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
|
- Ollama cloud usage is billed to whoever's key is used, so treat a shared Freezmo URL as shared key
|
||||||
access in `server` mode.
|
access in `server` mode.
|
||||||
- No accounts means no per-user isolation: anyone who can reach the URL can use the app (and, in
|
- 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 { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||||
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
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";
|
import { translate } from "@/lib/i18n-dict";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
@ -16,7 +18,7 @@ const MAX_BODY_BYTES = 33554432;
|
||||||
const MAX_IMAGE_CHARS = 8_000_000;
|
const MAX_IMAGE_CHARS = 8_000_000;
|
||||||
|
|
||||||
const requestSchema = z.object({
|
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(),
|
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 });
|
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 abort = new AbortController();
|
||||||
const timer = setTimeout(() => abort.abort(), config.timeoutMs);
|
const timer = setTimeout(() => abort.abort(), config.timeoutMs);
|
||||||
try {
|
try {
|
||||||
|
|
@ -97,6 +101,7 @@ export async function POST(request: Request) {
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
gate.gate.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM
|
||||||
import { friendlyErrorMessage, ollamaChatStream, OllamaError } from "@/lib/ollama";
|
import { friendlyErrorMessage, ollamaChatStream, OllamaError } from "@/lib/ollama";
|
||||||
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||||
|
import { acquireModelGate } from "@/lib/model-guard";
|
||||||
import { translate } from "@/lib/i18n-dict";
|
import { translate } from "@/lib/i18n-dict";
|
||||||
import { buildChatSystemPrompt } from "@/lib/prompts";
|
import { buildChatSystemPrompt } from "@/lib/prompts";
|
||||||
import { estimateTokens, trimHistory } from "@/lib/chat-trim";
|
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 encode = encoder();
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs);
|
const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs);
|
||||||
|
|
@ -170,6 +176,7 @@ export async function POST(request: Request) {
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(globalTimer);
|
clearTimeout(globalTimer);
|
||||||
|
gate.gate.release();
|
||||||
try {
|
try {
|
||||||
controller.close();
|
controller.close();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -179,6 +186,7 @@ export async function POST(request: Request) {
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
clearTimeout(globalTimer);
|
clearTimeout(globalTimer);
|
||||||
|
gate.gate.release();
|
||||||
abort.abort();
|
abort.abort();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM
|
||||||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||||
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||||
|
import { acquireModelGate } from "@/lib/model-guard";
|
||||||
import { translate } from "@/lib/i18n-dict";
|
import { translate } from "@/lib/i18n-dict";
|
||||||
import { difficultySchema } from "@/lib/schemas";
|
import { dataUrlSchema, difficultySchema } from "@/lib/schemas";
|
||||||
import { uid } from "@/lib/utils";
|
import { uid } from "@/lib/utils";
|
||||||
import { xpForActivity } from "@/lib/xp";
|
import { xpForActivity } from "@/lib/xp";
|
||||||
|
|
||||||
|
|
@ -18,7 +19,7 @@ export const maxDuration = 300;
|
||||||
const MAX_IMAGE_CHARS = 8_000_000;
|
const MAX_IMAGE_CHARS = 8_000_000;
|
||||||
|
|
||||||
const requestSchema = z.object({
|
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(),
|
text: z.string().max(120_000).optional(),
|
||||||
difficulty: difficultySchema.default("medium"),
|
difficulty: difficultySchema.default("medium"),
|
||||||
count: z.number().int().min(4).max(18).optional(),
|
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 requestedCount = count ?? config.defaultActivityCount;
|
||||||
const encode = encoder();
|
const encode = encoder();
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
|
|
@ -201,6 +207,7 @@ export async function POST(request: Request) {
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(globalTimer);
|
clearTimeout(globalTimer);
|
||||||
|
gate.gate.release();
|
||||||
try {
|
try {
|
||||||
controller.close();
|
controller.close();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -210,6 +217,7 @@ export async function POST(request: Request) {
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
clearTimeout(globalTimer);
|
clearTimeout(globalTimer);
|
||||||
|
gate.gate.release();
|
||||||
abort.abort();
|
abort.abort();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@
|
||||||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||||
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||||
|
import { acquireModelGate } from "@/lib/model-guard";
|
||||||
import { translate } from "@/lib/i18n-dict";
|
import { translate } from "@/lib/i18n-dict";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
@ -54,6 +55,8 @@ export async function POST(request: Request) {
|
||||||
return Response.json({ error: translate(locale, "server.invalidGrade") }, { status: 400 });
|
return Response.json({ error: translate(locale, "server.invalidGrade") }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gate = acquireModelGate(request, config, locale);
|
||||||
|
if ("response" in gate) return gate.response;
|
||||||
try {
|
try {
|
||||||
const result = await gradeShortAnswer({
|
const result = await gradeShortAnswer({
|
||||||
model: config.textModel,
|
model: config.textModel,
|
||||||
|
|
@ -72,5 +75,7 @@ export async function POST(request: Request) {
|
||||||
},
|
},
|
||||||
{ status: 502 },
|
{ 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 { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||||
import { translate } from "@/lib/i18n-dict";
|
import { translate } from "@/lib/i18n-dict";
|
||||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||||
|
import { acquireModelGate } from "@/lib/model-guard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
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 {
|
try {
|
||||||
const text = await refineHomeworkText({
|
const text = await refineHomeworkText({
|
||||||
model: config.textModel,
|
model: config.textModel,
|
||||||
|
|
@ -86,5 +89,7 @@ export async function POST(request: Request) {
|
||||||
},
|
},
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
gate.gate.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM
|
||||||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||||
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||||
|
import { acquireModelGate } from "@/lib/model-guard";
|
||||||
import { translate } from "@/lib/i18n-dict";
|
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";
|
import { uid } from "@/lib/utils";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
@ -20,12 +21,12 @@ const MAX_BODY_BYTES = 33554432;
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
mode: homeworkModeSchema,
|
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(),
|
text: z.string().max(120_000).optional(),
|
||||||
/** Mode "check": the student's own attempt at the assignment. */
|
/** Mode "check": the student's own attempt at the assignment. */
|
||||||
solution: z.string().max(120_000).optional(),
|
solution: z.string().max(120_000).optional(),
|
||||||
/** Mode "check": photos of the attempt (handwritten work, scanned pages). */
|
/** 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 of the assignment (usually detected by the vision step). */
|
||||||
language: z.string().max(60).optional(),
|
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 });
|
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 encode = encoder();
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
// A single hard deadline for the whole pipeline (two sequential model calls).
|
// A single hard deadline for the whole pipeline (two sequential model calls).
|
||||||
|
|
@ -257,6 +263,7 @@ export async function POST(request: Request) {
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(globalTimer);
|
clearTimeout(globalTimer);
|
||||||
|
gate.gate.release();
|
||||||
try {
|
try {
|
||||||
controller.close();
|
controller.close();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -266,6 +273,7 @@ export async function POST(request: Request) {
|
||||||
},
|
},
|
||||||
cancel() {
|
cancel() {
|
||||||
clearTimeout(globalTimer);
|
clearTimeout(globalTimer);
|
||||||
|
gate.gate.release();
|
||||||
abort.abort();
|
abort.abort();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,23 @@ export const dynamic = "force-dynamic";
|
||||||
* is false — no half-configured UI ever shows.
|
* 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({
|
const subscriptionSchema = z.object({
|
||||||
endpoint: z.string().url().max(2000),
|
endpoint: z.string().url().max(2000),
|
||||||
keys: z.object({
|
keys: z.object({
|
||||||
|
|
@ -96,9 +113,21 @@ export async function POST(request: Request) {
|
||||||
{ status: 400 },
|
{ 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);
|
const count = await addSubscription(config.push.dir, parsed.data, config.push.maxSubscriptions);
|
||||||
return Response.json({ ok: true, count }, { headers: { "Cache-Control": "no-store" } });
|
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) {
|
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() });
|
const line = JSON.stringify({ ...parsed.data, receivedAt: new Date().toISOString() });
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(config.reports.dir, { recursive: true });
|
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 {
|
} catch {
|
||||||
return Response.json({ error: "could not store the report" }, { status: 500 });
|
return Response.json({ error: "could not store the report" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@ export type ServerConfig = {
|
||||||
push: number;
|
push: number;
|
||||||
chat: number;
|
chat: number;
|
||||||
reports: 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: {
|
sync: {
|
||||||
/** Where opaque encrypted sync blobs live (SYNC_DIR). */
|
/** Where opaque encrypted sync blobs live (SYNC_DIR). */
|
||||||
|
|
@ -53,10 +59,20 @@ export type ServerConfig = {
|
||||||
subject: string;
|
subject: string;
|
||||||
/** Where push subscriptions live (PUSH_DIR). */
|
/** Where push subscriptions live (PUSH_DIR). */
|
||||||
dir: string;
|
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: {
|
reports: {
|
||||||
/** Where reported questions are appended, one JSON line each (REPORTS_DIR). */
|
/** Where reported questions are appended, one JSON line each (REPORTS_DIR). */
|
||||||
dir: string;
|
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),
|
chat: positiveInt(process.env.RATE_LIMIT_CHAT, 60),
|
||||||
// Question reports are cheap file appends.
|
// Question reports are cheap file appends.
|
||||||
reports: positiveInt(process.env.RATE_LIMIT_REPORTS, 20),
|
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: {
|
sync: {
|
||||||
dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "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() || "",
|
privateKey: process.env.VAPID_PRIVATE_KEY?.trim() || "",
|
||||||
subject: process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost",
|
subject: process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost",
|
||||||
dir: process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push"),
|
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: {
|
reports: {
|
||||||
dir: process.env.REPORTS_DIR?.trim() || path.join(process.cwd(), ".freezmo", "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.invalidOrigin": "Invalid Origin header.",
|
||||||
"server.crossOrigin": "Cross-origin requests are not allowed.",
|
"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.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":
|
"server.authMissingKey":
|
||||||
"Add your Ollama API key first — open the settings dialog (⚙️) and paste a key from ollama.com/settings/keys.",
|
"Add your Ollama API key first — open the settings dialog (⚙️) and paste a key from ollama.com/settings/keys.",
|
||||||
"server.authServerKeyMissing":
|
"server.authServerKeyMissing":
|
||||||
|
|
@ -1681,6 +1684,9 @@ const cs: Record<string, string> = {
|
||||||
"server.invalidOrigin": "Neplatná hlavička Origin.",
|
"server.invalidOrigin": "Neplatná hlavička Origin.",
|
||||||
"server.crossOrigin": "Požadavky z jiné domény nejsou povoleny.",
|
"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.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":
|
"server.authMissingKey":
|
||||||
"Nejdřív přidej svůj API klíč Ollama — otevři nastavení (⚙️) a vlož klíč z ollama.com/settings/keys.",
|
"Nejdřív přidej svůj API klíč Ollama — otevři nastavení (⚙️) a vlož klíč z ollama.com/settings/keys.",
|
||||||
"server.authServerKeyMissing":
|
"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(
|
export async function addSubscription(
|
||||||
dir: string,
|
dir: string,
|
||||||
subscription: { endpoint: string; keys: { p256dh: string; auth: 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 current = await readSubscriptions(dir);
|
||||||
|
const exists = current.some((entry) => entry.endpoint === subscription.endpoint);
|
||||||
const next = current.filter((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() });
|
next.push({ ...subscription, createdAt: Date.now() });
|
||||||
// A cap keeps a runaway script from filling the disk.
|
const capped = next.slice(-max);
|
||||||
const capped = next.slice(-2000);
|
|
||||||
await writeSubscriptions(dir, capped);
|
await writeSubscriptions(dir, capped);
|
||||||
return capped.length;
|
return { count: capped.length, accepted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeSubscription(dir: string, endpoint: string): Promise<number> {
|
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. */
|
/** Best-effort client identity: proxy headers first, then a shared bucket. */
|
||||||
export function clientKey(request: Request): string {
|
export function clientKey(request: Request): string {
|
||||||
const forwarded = request.headers.get("x-forwarded-for");
|
const forwarded = request.headers.get("x-forwarded-for");
|
||||||
if (forwarded) {
|
if (forwarded) {
|
||||||
const first = forwarded.split(",")[0]?.trim();
|
// Take the **last** hop, not the first: our reverse proxy appends the peer
|
||||||
if (first) return first;
|
// 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 (
|
return (
|
||||||
request.headers.get("x-real-ip") ??
|
request.headers.get("x-real-ip") ??
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,19 @@ import { z } from "zod";
|
||||||
export const difficultySchema = z.enum(["easy", "medium", "hard"]);
|
export const difficultySchema = z.enum(["easy", "medium", "hard"]);
|
||||||
export type Difficulty = z.infer<typeof difficultySchema>;
|
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 = {
|
const baseActivity = {
|
||||||
/** Short label shown in the feed chrome, e.g. "Cell biology". */
|
/** Short label shown in the feed chrome, e.g. "Cell biology". */
|
||||||
topic: z.string().min(1).max(120).optional(),
|
topic: z.string().min(1).max(120).optional(),
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,26 @@ const nextConfig: NextConfig = {
|
||||||
* the e2e suites are unaffected.
|
* the e2e suites are unaffected.
|
||||||
*/
|
*/
|
||||||
output: "standalone",
|
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: [
|
allowedDevOrigins: [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
"localhost",
|
"localhost",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "freezmo",
|
"name": "freezmo",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*
|
*
|
||||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
* 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -447,6 +447,8 @@ const browser = await launchBrowser();
|
||||||
VAPID_PRIVATE_KEY: vapid.privateKey,
|
VAPID_PRIVATE_KEY: vapid.privateKey,
|
||||||
VAPID_SUBJECT: "mailto:test@example.com",
|
VAPID_SUBJECT: "mailto:test@example.com",
|
||||||
PUSH_DIR: path.join(REPO, "tests", ".tmp", "push"),
|
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 {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const SUITES = [
|
||||||
"phase1-test",
|
"phase1-test",
|
||||||
"phase2-test",
|
"phase2-test",
|
||||||
"phase3-test",
|
"phase3-test",
|
||||||
|
"security-test",
|
||||||
"chat-test",
|
"chat-test",
|
||||||
"review-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