Files
minstrel/docs/security.md
T
bvandeusenandClaude Opus 5.5 a8a2d20307
release / govulncheck (push) Successful in 28s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m35s
release / android (push) Successful in 4m45s
release / Build signed APK (push) Successful in 4m57s
release / integration (push) Successful in 5m3s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: stored credentials are sealed at rest, so a copy of the database alone reveals none of them (#6767)
Minstrel keeps credentials it has to read back: indexer, Prowlarr and
download-client keys and passwords, the AcoustID key, cover-art and tag
provider keys, the SMTP password, and each user's ListenBrainz token and
Subsonic password. They were stored in the clear; they are now sealed with
AES-256-GCM under a per-column HKDF key derived from the stream secret, each
value bound to its row.

- internal/secret: Seal/Open/Reveal, and Columns, the one list of sealed
  columns (a backup will leave them out). Install takes the master key at
  startup; SealStored seals any value still in the clear, so an existing
  database is brought over in place on the first boot.
- Every write seals and every read opens: acquire (one read path, getService
  / usableServices), acoustid settings, SMTP, ListenBrainz, Subsonic auth and
  password generation, cover-art and tag provider settings.
- A value that does not open (the stream secret changed) is logged and reads
  as unset, to be entered again in Settings. docs/security.md says so.
- Tests: secret unit tests (wrong owner/purpose/key, malformed, no key), a
  SealStored integration test over all eight columns plus a schema check that
  each listed column exists, and the existing tests now assert sealed storage.
  dbtest installs a test key for integration tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:23:19 -05:00

6.3 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 /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).
  • 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.

Credentials Minstrel has to read back

Some credentials can't be hashed, because Minstrel has to send or compare the value itself:

  • the API keys and passwords for indexers, Prowlarr and download clients;
  • the AcoustID key, and the cover-art and tag provider keys;
  • the SMTP password;
  • each user's ListenBrainz token and Subsonic password.

These are encrypted in the database (AES-256-GCM). Each column has its own key, derived from the stream secret (MINSTREL_STREAM_SECRET, or the stream_secret file in the data directory), and each value is bound to its row. The stream secret is never stored in the database, so a copy of the database alone doesn't reveal any of them. None of them is ever sent back to a browser: Settings only shows whether one is set.

Keep the stream secret with the data directory, and don't change it casually. If it changes, every stored credential reads as not set, and each one has to be entered again in Settings (Subsonic passwords generated again). Minstrel logs a warning for each one that no longer opens.

Subsonic sign-in

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: the Subsonic password, encrypted as described above.

  • 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.
  • The Subsonic password is never your login password. Minstrel generates it (Settings → Subsonic password), shows it once, and lets you replace or turn it off. Because it is random, even someone holding both the database and the stream secret gains access to this server's Subsonic API and nothing else: it can't be a password you also use somewhere else.
  • t/s and p= sign-in are off for an account until it has a Subsonic password. Plain p= sign-in is additionally off server-wide unless subsonic.allow_plaintext_password is enabled.
  • minstrel admin reset-password changes only the login password. Older versions also copied it into the Subsonic password; upgrading clears every Subsonic password once, so those copies are gone. An account that used t/s sign-in needs a new Subsonic password generated in Settings.

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 every web dependency, build tooling included. 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.