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:
Martin Dvořák 2026-10-12 20:40:57 +00:00
commit 8822baf4b7
21 changed files with 528 additions and 21 deletions

View file

@ -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