# Security Heddohon sits between the public internet and a music server that usually lives on a private network. This document covers the controls it implements, where they stop, and what it expects from the operator. Every claim here can be checked against the source. ## Reporting a vulnerability Report privately through GitHub's **Report a vulnerability** button on the Security tab. Include what you did, what happened and what you expected. A reproduction against a local instance is the most useful thing you can send. There is no bounty. ## Threat model - **Hostile:** the public internet, anyone who can reach the login page, any signed-in account acting against another, and the library itself (metadata, cover art, lyrics), since anyone with write access to a shared library controls it. - **Semi-trusted:** the upstream music server. Heddohon trusts it for content and keeps control of what executes in the browser (see [Proxied media](#proxied-media)). - **Trusted:** the operator, the host, the reverse proxy and the container environment. - **Out of scope:** anyone with shell access to the host or read access to the data volume. Credential encryption protects a stolen database file; an attacker running code beside the process can decrypt what it decrypts. ## Design The browser only talks to Heddohon, and Heddohon fetches every byte from the music server itself. The upstream address stays inside the server process: it appears in no page, bundle or error message, and the login page receives only each server's label and kind. The upstream credential leaves the process only as a request to the music server. The one host you expose is Heddohon. ## Credentials at rest - Upstream credentials are sealed with **AES-256-GCM** before they reach the database. - Keys are derived with **scrypt** (N = 2¹⁵, r = 8, p = 1) from `HEDDOHON_SECRET`. Three separate keys cover encryption, session digests and pseudonyms. - Each seal uses a fresh 12-byte random IV. The tag is verified on open, and a blob with a wrong version, part count, IV length or tag length is rejected. - Changing `HEDDOHON_SECRET` makes every stored credential unreadable and signs everyone out. Use it to revoke everything at once. **Subsonic stores the password.** The protocol needs it on every request (`md5(password + salt)`), so the sealed value is the password itself. Jellyfin issues a token at sign-in and Heddohon discards the password. ## Sessions The cookie is a **256-bit random token** with no payload. The database stores only `HMAC-SHA256(token)`, so reading the database yields no usable cookie. | Property | Value | | --- | --- | | Name | `__Host-heddohon_session` when `Secure`, `heddohon_session` otherwise | | Flags | `HttpOnly`, `SameSite=Lax`, `Path=/`, host-only, `Secure` (see below) | | Lifetime | `HEDDOHON_SESSION_HOURS`, default and hard ceiling **72 hours** | | Extension | Activity updates `last_seen_at` only; `expires_at` is fixed at creation | Expiry is checked on every request, and an expired row is deleted when presented. Signing out deletes the server-side record. When the upstream rejects a stored credential, every session for that account is destroyed. **`Secure` flag.** `HEDDOHON_COOKIE_SECURE=auto` (the default) turns it on for `https` requests, when `NODE_ENV=production`, or when the host is not loopback. The shipped image sets `NODE_ENV=production`, so a deployment reached over plain http needs `HEDDOHON_COOKIE_SECURE=false`. **One cookie name is read.** Accepting both names would let a sibling subdomain plant `heddohon_session` with `Domain=.example.com` and pin a session that signing out cannot clear. Browsers refuse a `__Host-` cookie with a `Domain`, which closes that. Switching between the two names signs sessions out once. ## Cross-origin writes SvelteKit's CSRF check covers form content types only, and every state-changing call in Heddohon is JSON. An explicit origin check covers them: - **It applies to every mutating request, on every path.** A check scoped to `/api/` was bypassable, because SvelteKit routes on the decoded path: `PATCH /%61pi/settings` reached `/api/settings` without matching the prefix. - **A missing `Origin` is refused.** Browsers always send it on non-GET/HEAD requests. Scripted calls need `-H "origin: "`. `SameSite=Lax` is a second layer underneath. ## Sign-in throttling Every sign-in is proxied to the music server from Heddohon's address, so the upstream's own lockout or fail2ban sees a single client. Heddohon throttles before the upstream is called. | Key | Limit | Window | | --- | --- | --- | | Username | 10 attempts | 15 minutes | | Source address | 60 attempts | 15 minutes | - An attempt is counted before the upstream call and refunded if the call gives no verdict, so concurrent requests each pay for their check. - Only a rejected credential counts. An unreachable server locks nobody out. - A successful sign-in clears the username counter. Counters live in SQLite and survive restarts. - The username limit expires on its own, so it can slow a guesser without keeping a real user out. - The address counter is skipped when the client address is loopback or RFC1918, which is what a proxy on the same host or Docker network looks like. Counting there would put every visitor in one bucket. A line is logged when this happens. - Failures give the same response for an unknown user and a wrong password. ## Proxied media The upstream reports a content type for covers and audio, and the browser applies it to a response from Heddohon's origin. A library file described as `text/html` would run script with full access to the signed-in API. Three controls prevent that: - **Constrained type.** Covers must be `image/*` and streams `audio/*`. Anything else is sent as `application/octet-stream`, which browsers download. - **Sandboxed responses.** Every media response carries `Content-Security-Policy: default-src 'none'; sandbox`, which leaves `` and `