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
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>
126 lines
6.3 KiB
Markdown
126 lines
6.3 KiB
Markdown
# 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.
|
|
|
|
## 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](./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 every web dependency, build tooling included. 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.
|