docs: hosting guide and security notes; README setup and HTTPS guidance (M462 #4986)

- docs/hosting.md: LAN vs internet; binding 4533 to 127.0.0.1 behind an
  HTTPS proxy (Caddy example, no buffering, long read timeouts for SSE and
  streams); the Client IP detection hop count (default 1, so 0 with no
  proxy or clients can forge X-Forwarded-For); the public address that
  password-reset links need; finding the setup token.
- docs/security.md: sessions, API keys, rate limits, headers and CSP; why
  CSRF rests on SameSite=Strict plus JSON-only cookie writes; the Subsonic
  password column, including the known issue that admin reset-password
  writes the login password there (#5026); why Android allows plain HTTP;
  the CI publish gate.
- README: keeps the LAN-first port mapping with a pointer for internet
  hosts, scopes "plain http:// is fine" to trusted networks, explains the
  setup token in first-run step 1, and links both docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 10:37:32 -04:00
co-authored by Claude Opus 5.5
parent 6de8d4136d
commit 522503e011
3 changed files with 200 additions and 2 deletions
+101
View File
@@ -0,0 +1,101 @@
# Security notes
What Minstrel does to protect accounts, and the reasoning behind the
decisions that look odd at first. For deployment steps, see
[hosting](./hosting.md).
## Accounts and sessions
- **Passwords** are stored as bcrypt hashes.
- **Login, registration and password reset are rate-limited:** 10 failed
sign-ins per account and 50 per address per 15 minutes, with similar limits
on register, forgot-password and reset. The Subsonic `/rest` API shares the
login limits. An unknown username takes as long to reject as a wrong
password, so timing doesn't reveal which accounts exist.
- **Sessions** are random 256-bit tokens; the server stores only their
SHA-256. A session ends after 30 days unused or 365 days in total. Changing
your password signs out your other devices; a password reset, or an admin
resetting it, signs out all of them.
- **API keys** (the OpenSubsonic `apiKey`) are stored as SHA-256 too, so a
key is shown once, when you generate it in Settings, and can only be
replaced after that.
- **The first account** on an empty server needs the setup token from the
server log (see [hosting](./hosting.md#first-account)).
- **Password-reset links** are built only from the configured public address,
never from the request's `Host` header.
## Requests
- Request bodies are capped at 4 MiB and must arrive within 30 seconds.
Streams and the live-event connection are unaffected.
- Every response carries `X-Content-Type-Options: nosniff`,
`Referrer-Policy: strict-origin-when-cross-origin`, a restrictive
`Permissions-Policy` and `X-Frame-Options: DENY`. The web app also gets a
`Content-Security-Policy` that allows only its own scripts, by hash.
- Media responses are `Cache-Control: private`, so a shared cache never keeps
one user's audio or artwork for another.
## CSRF: SameSite cookies plus JSON-only writes
There are no CSRF tokens. The session cookie is `SameSite=Strict`, so
browsers don't send it with requests that start on another site. That leaves
one gap: SameSite treats every subdomain of the same registrable domain as the
same site, so a different app on `other.example.com` could still send a
request carrying the cookie. To close it, any state-changing `/api` request
authenticated by the cookie must have a JSON body (`Content-Type:
application/json`) or no body at all. An HTML form or a script on another
origin can't send JSON without a CORS preflight, and Minstrel answers no
cross-origin preflight. Requests authenticated with a bearer token or the
Subsonic query parameters carry nothing a browser attaches on its own, so
they aren't checked.
## Subsonic sign-in, and the one password stored in plain text
Classic Subsonic clients sign in with `t` and `s`: the MD5 of the password
followed by a random salt. To check that, the server has to know the password
itself, so supporting this sign-in method means storing a password Minstrel
can read. That is the `subsonic_password` column, and it is the only
credential Minstrel keeps unhashed.
- **The recommended way in is the API key.** Clients that support the
OpenSubsonic `apiKey` should use it. The key is stored hashed and can be
replaced at any time in Settings.
- **`t`/`s` and `p=` sign-in are off for an account until its
`subsonic_password` is set**, and nothing in the app sets it. Plain `p=`
sign-in is additionally off server-wide unless
`subsonic.allow_plaintext_password` is enabled.
- **Known issue:** `minstrel admin reset-password` writes the new login
password into `subsonic_password` as well, so `t`/`s` clients keep working
after a recovery. For an account reset that way, the login password is
stored in plain text until the column is cleared.
## Android allows plain HTTP
The Android app permits cleartext connections, for two reasons that can't be
narrowed to a list of hosts. The server address is whatever the user types,
and many self-hosted servers run plain HTTP on a LAN. And UPnP, DLNA and
Sonos speakers are controlled over plain HTTP at addresses that are only
known once they're discovered. The reasoning lives in
`android/app/src/main/res/xml/network_security_config.xml`.
This doesn't weaken app updates: an APK altered in transit fails the
platform's signature check. It does mean a server reached over plain HTTP
across the internet exposes the session cookie in transit, which is why the
[hosting guide](./hosting.md) puts public servers behind HTTPS.
On the phone, the session cookie is encrypted with a key held in the Android
Keystore, so a copy of the app's files doesn't yield a usable session.
## Build pipeline
Nothing is published unless every check passes: the Go, integration, web and
Android test suites, `govulncheck` against the toolchain that builds the
image, and `npm audit` on the packages that ship to the browser. See
`.gitea/workflows/release.yml`.
## Reporting a problem
Open an issue on the
[repository](https://git.fabledsword.com/bvandeusen/minstrel/issues), or
contact the maintainer privately first if it's something that shouldn't be
public until fixed.