Repository navigation
v0.1.0 Security
Note: Documentation of Guestroll v0.1.0. The latest release is v0.5.0: this page in v0.5.0 · what changed · other versions
Guestroll holds other people's photos, often of children, at private events. This page lists what it protects, against whom, and how. If you find a gap, please report it privately (see SECURITY.md).
| Asset | Who may access it |
|---|---|
| A guest's photos | That guest (from the browser that took them) and the admins |
| Everyone else's photos | Admins only. Never another guest. |
| Event settings, stats, QR codes | Admins only |
| The event page itself | Anyone with the QR code, plus the access code if one is set |
There is no public gallery and no guest download of other people's photos, whatever the settings: this is a design decision, not an option.
-
No public files. Photos are stored outside the web root
(
DATA_DIR/uploads) and only leave the server through/api/media/<id>, which checks access on every request. A copied URL is useless in another browser: answers are identical (404) for "doesn't exist" and "not yours". - Joining is a cookie, not a link. Opening an event page writes the event into an encrypted guest cookie. Media requests require it, so a photo URL pasted into a chat doesn't open anywhere else.
- Unguessable identifiers. Photo ids are random UUIDs, event slugs carry ~40 bits of randomness. Slugs aren't the security boundary (the cookie is), but they keep events from being enumerated.
-
Private caching. Media responses are
Cache-Control: private, originalsno-store, so no shared proxy or CDN keeps a copy. - Rotating links. "Generate a new link" changes the slug: the old QR code stops working immediately, e.g. after it leaked on social media.
- Optional access code per event, hashed with bcrypt.
- Files are identified by their bytes, never by name or
Content-Type; only JPEG, PNG, WebP, HEIC/HEIF, AVIF and GIF are accepted. - Every image is re-encoded: EXIF metadata (GPS position, device serial numbers) is removed, and anything appended after the image data is dropped.
- Size is capped while the body streams in, so chunked requests without
Content-Lengthcan't exhaust memory. Images aboveMAX_IMAGE_MEGAPIXELSare refused before decoding. - One upload at a time per guest, so parallel requests can't bypass the per-guest quota.
- One admin account, password from
ADMIN_PASSWORDor generated on first start and printed once. There is no public "create the admin" page that a stranger could reach first. - Sessions are encrypted,
HttpOnly,SameSite=Lax,Securein production cookies (iron-session). Changing the password signs out every other session. - Every admin page and API route checks the session itself.
tests/routeGuards.test.tsfails if one is added without the check.
| Limit | Default |
|---|---|
| Admin login | 5 attempts per minute per IP |
| Access code | 10 attempts per minute per guest, 30 wrong codes per minute per event |
| Uploads | 30 per minute per guest, plus the per-guest quota |
The per-event ceiling on wrong codes is a deliberate trade-off: someone flooding wrong codes can make real guests wait up to a minute, but can't brute-force a code of 6+ characters (enforced) in any useful time.
Every state-changing request (admin and guest) must carry a matching Origin
header, on top of SameSite cookies. The app sets X-Frame-Options: DENY,
X-Content-Type-Options: nosniff, a strict Referrer-Policy, and a
Permissions-Policy that allows the camera only for the app itself. Served
images and SVG files carry a sandboxing Content-Security-Policy.
- Serve it over HTTPS. Phones need it for the camera, and cookies are
Securein production. - Set
TRUST_PROXY=truebehind your reverse proxy, and only then. - Back up
DATA_DIRand keep it private: it holds every photo and the session secret. - Pin an image version and follow releases for security fixes.