Threat model
Six kinds of attacker, from a stranger on the internet to a malicious file, and what stands between each of them and the household's media and accounts.
Who the attackers are
| Attacker | What they have | What they would want |
|---|---|---|
| A stranger on the internet | The install's public name, when remote access is on | An account; to keep the household out; to use the server's resources |
| Someone on the same Wi-Fi | Plain-http traffic on the LAN; the ability to answer router discovery | Session cookies, photos in transit, to steer an app to their own server |
| A member of the household | A real account, upload rights to the shelves they can see | Another person's photos or account, the owner's powers, shelves they were refused |
| A crafted file | Whatever a member uploads: EPUB, PDF, MP3, M4A, FLAC, image, zip | To run script as whoever opens it, or to exhaust memory and CPU |
| A malicious backend or service | Control over answers SoundStorm reads: a backend, plex.tv, a router, an ACME server | To redirect credentials elsewhere, crash the server, or reach internal services |
| Another install | A trusted name under the same domain as every install | Cookie tossing, cross-site requests, the name service's shared budgets |
Before the first account
Until an account exists, whoever reaches the port could claim the server. "Only from the home
network" cannot be checked - under Docker Desktop every connection arrives from Docker's own
address - so the first sign-up needs a setup code: eighty random bits the installer
writes into .env, shows at the end of setup, and passes to the browser it opens.
Signup closes for good the moment an account exists, and the role of every later account is decided
by the server under a lock, so two first sign-ups racing cannot both become owner.
Signing in
- Passwords are PBKDF2-SHA256 at 600,000 rounds, at most 256 characters (a longer one would cost more per guess than the throttle was sized for). An unknown name is hashed too, at the same cost.
- Only two hashes run at once for everyone; a client gets five free failures and then a doubling wait capped at five minutes; an account gets ten, capped at a minute; one guess per account is in flight at a time, so a burst cannot outrun the backoff.
- A browser that has signed in before carries a device token (an HMAC over the account's current salt) and is judged on its own record, with a hashing slot of its own - so a stranger guessing at the owner's name cannot keep the owner out.
- Sessions are random 256-bit tokens stored only as hashes; over TLS the cookie is
__Host-, which another install's page cannot overwrite. - A new password must be at least 12 characters and not a common one. The owner can make new devices wait for approval from a device already signed in, so a guessed or leaked password alone opens nothing, and can ask everybody for a new password.
- Switching person on a shared device ("Who's listening?") needs that person's PIN if they set one, and the owner's always; wrong PINs are counted before they are checked, and fifteen take the person off the device. A TV signed in from a phone is vouched for by that phone, with a one-time code that lasts ten minutes.
What a member can reach
Two roles: the owner and members. A member's access is a list of media kinds; the middleware puts
it in the request context, and Registry.All, Matching and ByID
take a context, so no handler can reach a source without the restriction applied. Jellyfin serves
films and TV from one account, so every Jellyfin target checks the item comes back from a query
limited to its own types - an id alone cannot cross from TV to films.
Photos are private by design: each member has a folder of their own and an Immich account whose only library is that folder, so faces, places and search see their photos alone. The owner sees everybody's. Deleting, people, settings and remote access are the owner's routes, mounted behind an owner check.
Files from other people
The most-tested boundary. A book's chapters are rendered in same-origin frames, so a script inside a book would run with the reader's session. Defences, in layers:
- The shell's Content-Security-Policy allows scripts only from the server itself, and no inline script.
- The reader strips scripts, event attributes,
javascript:links, frames, objects, SVG animation, non-stylesheet links, XSLT, and any reference back to the server, from every chapter before it is shown. - The server never serves a script type from a book, a cover or a stream (demoted to
text/plain), refuses requests whose destination is a script, worker or style, and sandboxes HTML, XML and SVG. app.jsandreader.jsrefuse to run inside a frame at all.
Every parser is bounded: EPUB directories checked before archive/zip reads them, XMP packets and OPF documents capped, image decodes limited to 12 megapixels and two at a time, MP4 box nesting and tag walks capped, a zip that would unpack to far more than its size refused.
Backends and outside services
- A path is an instruction to a backend holding admin credentials. The shared client refuses absolute references, second leading slashes and dot segments after decoding; HLS paths must match Jellyfin's own shapes; subtitle and trickplay parameters that would make Jellyfin write its token into a playlist are stripped.
- Redirects to another host are not followed with a backend's headers; Plex servers are dialled only at addresses that are not loopback, link-local, CGNAT, Docker's or the container's own networks, checked again as each connection is made.
- Transport errors are redacted before anyone sees them: a Subsonic URL carries its credential in the query.
The network and the apps
- Cross-site writes are refused on origin, not site - every install shares a parent domain until it is on the Public Suffix List.
- The local certificate authority is name-constrained to the install's own networks, so a leaked key cannot mint a certificate for a bank.
- The name service only ever names private addresses for the home name, and the public name only after it has reached the install itself.
- The phone apps follow the page to the install's secure name but never save a name a page asked for; the Android app plays only the server's own addresses, sends cookies only to the host they belong to, and lets only trusted apps control playback.