From 4dc3dfe5283da2d67ac79d879103a7c98577ac03 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 15 Oct 2026 23:05:47 +0200 Subject: [PATCH] API, Development: what the server refuses and hands out (#427) - API: 429 at the sign-in routes, a section on the brake, the quota, writes from other sites and the Secure cookie, and the upload limits as they are now (the page still said nginx takes 1 MB). - Development: a Deployment section with the nginx list, the Content-Security-Policy and where both live, the two new PHP tests. - Architecture: LoginThrottle.php and Quota.php. Co-Authored-By: Claude Opus 5.5 --- API.md | 51 +++++++++++++++++++++++++++++++++++++------------ Architecture.md | 3 +++ Development.md | 33 +++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/API.md b/API.md index 1c44e9f..d99d1fc 100644 --- a/API.md +++ b/API.md @@ -3,9 +3,9 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT | Method | Route | Body → Result | |---|---|---| | GET | `auth/session` | `{user, role}` — the name and role (`admin` or `user`) of whoever is signed in, both `null` when nobody is. Always 200 | -| POST | `auth/login` | `{username,password}` → `{user, role}` and the session cookie; **401** `{error}` for a wrong pair | +| POST | `auth/login` | `{username,password}` → `{user, role}` and the session cookie; **401** `{error}` for a wrong pair, **429** while the address is locked out (see below) | | POST | `auth/logout` | 204, and the cookie is cleared | -| POST | `auth/password` | `{currentPassword,newPassword}` → 204. Changes the password of whoever is signed in — the only `auth` route that needs a session. **401** for a wrong current password, **422** when the new one is shorter than 8 characters. Every other session of the account ends; the one asking keeps its own | +| POST | `auth/password` | `{currentPassword,newPassword}` → 204. Changes the password of whoever is signed in — the only `auth` route that needs a session. **401** for a wrong current password, **422** when the new one is shorter than 8 characters, **429** while the address is locked out. Every other session of the account ends; the one asking keeps its own | | GET | `projects` | `[{id,name,revision,updatedAt}]` | | POST | `projects` | `{name}` → project document (201) | | GET | `projects/{id}` | `{id,name,revision,updatedAt,project}` | @@ -51,6 +51,33 @@ all the same. The `users` routes answer anybody but an administrator **403** `{"error":"Administrators only"}`. Cels and plugins are shared between accounts; being signed in is still the price of admission. See [Accounts](Accounts). +## What the server refuses (#427) + +- **Wrong passwords:** five failed sign-ins (or wrong current passwords) from one address within + 15 minutes, and that address gets **429** `{"error":"Too many failed sign-ins. Try again in N minutes"}` + with `Retry-After` until 15 minutes after the last of them. The right password waits as well, and + a successful sign-in does not reset the count, or an account of one's own would be the way to do + that. The address is the one nginx takes from the last entry of `X-Forwarded-For`, which the + Brandmauer appends; anything the client wrote in front of it is ignored. The failures lie in + `data/sign-ins/` (`api/lib/LoginThrottle.php`). +- **Space:** an account takes up at most **1 GB** with its uploads, its projects and their + snapshots (`api/lib/Quota.php`). An upload, a save, a new project or snapshot or a restore that + would make it larger is refused with **413** `{"error":"Not enough space: this account uses … of its 1024 MB"}`. + A write that makes nothing larger always goes through, so a full account can still save a + smaller project or delete files. Administrators have no limit. Cels belong to nobody and are left + out. +- **Other sites:** any request but GET is refused with **403** when the browser says it comes from + another site (`Sec-Fetch-Site: cross-site` or `same-site`). The session cookie is + `SameSite=Lax`, and to that every subdomain of the site is the same site. Tools send no such + header. +- **The cookie** carries `Secure` whenever the request reached the Brandmauer over HTTPS, along + with `HttpOnly` and `SameSite=Lax`. + +`tests/php/rights.test.php` runs the API under `php -S` on a scratch directory and asks every route +as somebody signed out, as the owner, as another account and as an administrator. It also reads the +route list at the top of `api/index.php` and fails for a route the matrix does not ask about, so a +new route has to be put there with what it expects. + ## Uploaded files belong to an account (#344) Every account uploads images, fonts, sounds and videos into a **folder of its own**, and the folder is part @@ -97,11 +124,10 @@ currently 2). There are no load-time migrations: `serialize.ts` reads what it re the rest. Uploads send the bytes as the body rather than `multipart/form-data`: no boundary parsing in -PHP, and PHP's `upload_max_filesize` (2 MB) never applies — only `post_max_size`. The API has its -own caps behind nginx's: 20 MB per request, 4 MB per image or font and 16 MB per sound (413) — -a single music track is worth more than four typefaces. **nginx's own limit is the one that -actually bites** (1 MB today, see `MAX_UPLOAD_BYTES` in `textureImport.ts`): raising the API's -cap without raising that one changes nothing. The stored files are served statically from +PHP, and PHP's `upload_max_filesize` never applies — only `post_max_size`. nginx and the +Brandmauer let 2 GB through, and the API has its own caps behind them (413): 256 MB per request, +4 MB per image or font, 256 MB per sound, 2 GB per video, which alone is streamed to disk. The +quota above comes on top. The stored files are served statically from `data/textures/`, `data/fonts/`, `data/sounds/` (each in its owner's folder) and `data/pixels/`, so reading one never runs PHP. An image is downscaled and re-encoded in the browser when it would not fit; a font or a sound cannot be, so an oversized one is refused rather than shrunk. @@ -127,10 +153,11 @@ pickers show; an upload that carries no `name` leaves the known one alone rather A file uploaded before names were kept simply has `""`, and reads as it always did. **What the web server hands out as files** is only images, fonts, sounds, videos (with their -posters, details and the uploads as they came) and cels. The jobs in `data/videos/queue/` are -hidden like a project. Everything else -under `data/` (projects, snapshots, releases, accounts, sessions) is written readable for PHP alone -(`0640`, `AtomicFile::HIDDEN`), so nginx answers **403** for it and the sign-in cannot be walked -around. Every file the API writes goes through `AtomicFile`: a temp file beside it, its mode, then a +posters and the uploads as they came) and cels, matched by folder, hash and ending; their name +files, the video details and the jobs in `data/videos/queue/` are not. Everything else under +`data/` answers **404**, see [Development](Development#deployment). On top of that, projects, +snapshots, releases, accounts, sessions and failed sign-ins are written readable for PHP alone +(`0640`, `AtomicFile::HIDDEN`), so a mistake in the nginx list alone would still not hand them out. +Every file the API writes goes through `AtomicFile`: a temp file beside it, its mode, then a rename, so no reader sees half a file or a file with the wrong mode. Saves of one project take turns on a lock file beside it (`{id}.json.lock`), which stays when the save is done. diff --git a/Architecture.md b/Architecture.md index 39de497..8f71a7d 100644 --- a/Architecture.md +++ b/Architecture.md @@ -544,6 +544,9 @@ api/ PHP backend: index.php (routing and the sign-in gate; `ro lib/{Auth,UserStore,SessionStore}.php (#177: accounts in data/users.json, sessions in data/sessions/) lib/SnapshotStore.php (#231: data/snapshots/{projectId}/, one copy of the project per file) lib/ReleaseStore.php (#266: data/releases/{token}.json and which token a project has) + lib/LoginThrottle.php (#427: five wrong passwords from one address, then 15 minutes of + 429; data/sign-ins/), lib/Quota.php (#427: 1 GB per account for uploads, projects and + snapshots, 413 for a write that would grow past it) lib/{VideoStore,VideoQueue,VideoFormat,VideoWorker}.php (#402: a video named after its upload, the queue in data/videos/queue/, the one target format as plain rules, the conversion with ffmpeg; run by tools/video-worker.php in the container of tools/video/ and in CI) diff --git a/Development.md b/Development.md index adeb7dd..cad30eb 100644 --- a/Development.md +++ b/Development.md @@ -21,6 +21,9 @@ php tests/php/releases.test.php # the release stor php tests/php/files.test.php # uploaded files in a folder per account (#344), the same way php tests/php/videos.test.php # the video store, its queue and the worker (#402), with the # real ffmpeg: the CI image and feuerwerk-video have it +php tests/php/limits.test.php # the brake on wrong passwords and the quota (#427) +php tests/php/rights.test.php # every route as signed out, owner, stranger and admin (#427): + # starts the API under `php -S` on a scratch directory node tools/seed-e2e.mjs && node tools/e2e.mjs # 2088 editor checks, screenshots in /tmp/gm-e2e-*.png GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 — a subset while working on one step ./e2e.sh start [GM_STEPS] # the same run detached, see below @@ -281,7 +284,35 @@ faster than a straight line. `tools/smoke-actor.mjs` walks it in a headless brow animation really advances it reads off the **drawn pixels**, standing at the edge of the camera where he walks on the spot — a clock that ticks without the picture following would pass otherwise. -The workspace folder itself is what nginx serves, so `npm run build` is the deployment. +## Deployment + +The workspace folder itself lies behind `https://nockal.com/feuerwerk/`, so `npm run build` is the +deployment, and a change to `api/` is live the moment it is saved. nginx hands out only what the +pages load (#427), from a `location ^~ /feuerwerk/` block in the shared `nginx.conf` of the web +stack: + +- `index.html` and `play.html`, `dist/`, `public/brand/`; +- the uploaded files in `data/textures|fonts|sounds|videos|pixels/`, by folder, hash and the + endings their stores give them, with `Content-Security-Policy: default-src 'none'; sandbox`; +- `api/index.php`, the only PHP that runs. `display_errors` is off and `X-Powered-By` is hidden. + +Everything else in the workspace (`logs/`, `tmp/`, `tools/`, `tests/`, `wiki/`, `node_modules/`, +the source) answers **404**. Over plain HTTP the bare folder is a 301 to HTTPS, the two pages forward +in the browser (the Brandmauer rewrites every other redirect), and the API answers 403. + +The pages run under a **Content-Security-Policy** without inline code and without `eval`: +`vite.config.ts` writes it into both built pages as a ``, so CI runs the whole suite under +it, and the harness fails a module for anything Chrome refuses. Pixi gets by without `eval` +through `pixi.js/unsafe-eval` (imported in `src/page.ts`); Rapier needs `'wasm-unsafe-eval'`. nginx +adds `nosniff`, `frame-ancestors 'self'`, HSTS and a `Referrer-Policy` to everything under the path. +A new kind of file the editor loads, or a new place it loads one from, needs both lists: the +policy in `vite.config.ts` and the `location` in `nginx.conf`. + +Every PHP script outside `api/` stops at once unless it runs on the command line. + +The video worker (`tools/video/compose.yml`) runs without network, without capabilities, with the +checkout read-only and only `data/videos/` writable, and with 2 GB of memory and 256 processes at +most: ffmpeg reads what strangers upload. ## Commit messages