- 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>
5.0 KiB
Security notes
What Minstrel does to protect accounts, and the reasoning behind the decisions that look odd at first. For deployment steps, see hosting.
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
/restAPI 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).
- Password-reset links are built only from the configured public address,
never from the request's
Hostheader.
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 restrictivePermissions-PolicyandX-Frame-Options: DENY. The web app also gets aContent-Security-Policythat 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
apiKeyshould use it. The key is stored hashed and can be replaced at any time in Settings. t/sandp=sign-in are off for an account until itssubsonic_passwordis set, and nothing in the app sets it. Plainp=sign-in is additionally off server-wide unlesssubsonic.allow_plaintext_passwordis enabled.- Known issue:
minstrel admin reset-passwordwrites the new login password intosubsonic_passwordas well, sot/sclients 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 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, or contact the maintainer privately first if it's something that shouldn't be public until fixed.