From 522503e011ebc6281cd8e3a9afde11da17c4783b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 10:37:32 -0400 Subject: [PATCH] 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 --- README.md | 9 ++++- docs/hosting.md | 92 ++++++++++++++++++++++++++++++++++++++++++ docs/security.md | 101 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 docs/hosting.md create mode 100644 docs/security.md diff --git a/README.md b/README.md index f1872700..3437ba73 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz services: minstrel: image: git.fabledsword.com/bvandeusen/minstrel:latest + # Reachable from your LAN at http://:4533. If this host faces the + # internet, bind it to 127.0.0.1 and put an HTTPS proxy in front instead: + # see docs/hosting.md. ports: ['4533:4533'] volumes: # Your music library. Point ./music at wherever your audio files @@ -76,9 +79,9 @@ docker compose up -d ## First run -With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address (plain `http://` is fine — no TLS required). +With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address. Plain `http://` is fine on a network you trust; a server reachable from the internet belongs behind HTTPS, which [docs/hosting.md](docs/hosting.md) walks through. -**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5). +**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance becomes the administrator, and creating it asks for the **setup token** the server prints in its log (`docker compose logs minstrel | grep setup_token`), so nobody else can claim a newly exposed server first. Later users join through the same form or an invite token (step 5). Creating the first (admin) account on a fresh instance @@ -100,6 +103,8 @@ With the stack up, a handful of in-app steps get you to a working library. Use y For the full configuration surface, see [`config.example.yaml`](./config.example.yaml). +Hosting Minstrel on the internet: see [docs/hosting.md](docs/hosting.md). What Minstrel does to protect accounts, and why: [docs/security.md](docs/security.md). + ## Configuration Most operators only need the env vars in the quickstart above. A few extras worth knowing: diff --git a/docs/hosting.md b/docs/hosting.md new file mode 100644 index 00000000..10ede42e --- /dev/null +++ b/docs/hosting.md @@ -0,0 +1,92 @@ +# Hosting Minstrel + +Minstrel serves plain HTTP on port 4533 and never terminates TLS itself. On a +home network or a VPN you trust, that is all you need. Once the server is +reachable from the internet, put it behind a reverse proxy that speaks HTTPS, +and tell Minstrel about that proxy. This page covers both. + +## On a LAN or VPN + +Publish the port to the network and use `http://:4533`: + +```yaml + ports: ['4533:4533'] +``` + +If nothing sits in front of Minstrel, set **Admin → Integrations → Client IP +detection** to 0. It defaults to 1, which assumes one proxy (see below for +why that matters). + +## On the internet + +Three things, all required: + +1. **Don't publish 4533 to the world.** Bind it to the loopback interface so + only the proxy on the same host can reach it: + + ```yaml + ports: ['127.0.0.1:4533:4533'] + ``` + + If the proxy runs in another container on the same Docker network, drop + `ports:` entirely and point the proxy at `minstrel:4533`. + +2. **Terminate HTTPS at a reverse proxy.** Any proxy works. With Caddy, which + gets and renews certificates by itself: + + ``` + music.example.com { + reverse_proxy 127.0.0.1:4533 + } + ``` + + Two settings matter for every proxy: + - **Don't buffer responses.** Live updates use Server-Sent Events and audio + is streamed; a buffering proxy holds both back. Caddy streams by default. + For nginx, set `proxy_buffering off;`. + - **Allow long-lived responses.** An event stream stays open for as long as + the app is. Raise the proxy's read timeout well past a minute (nginx: + `proxy_read_timeout 1h;`). + +3. **Tell Minstrel where it lives**, in **Admin → Integrations**: + - **Client IP detection:** the number of proxies in front of Minstrel. One + proxy (Caddy, Traefik, nginx) is 1; Cloudflare in front of Traefik is 2. + Minstrel uses this to find the real client address, for login rate + limits and the sessions list, and to tell whether a request arrived over + HTTPS. + - **Public address:** the URL people use, e.g. `https://music.example.com`. + Password-reset emails link here. **Until it is set, no reset email is + sent**, so a forged `Host` header can never put a reset token on a link + to someone else's server. + +### Why the proxy count matters + +Minstrel only believes `X-Forwarded-For` and `X-Forwarded-Proto` from the +number of proxies you configure, counted from the right. Set it too high, or +leave it at the default of 1 with no proxy in front, and a client can write +those headers itself: it could claim any address to slip past the per-address +login limit, or claim HTTPS. With no proxy, set it to 0. + +When the proxy reports HTTPS, Minstrel marks the session cookie `Secure` and +sends `Strict-Transport-Security`. Over plain HTTP it does neither, and it +never redirects to HTTPS, so LAN use keeps working unchanged. + +## First account + +A fresh server with no accounts prints a **setup token** in its log: + +``` +docker compose logs minstrel | grep setup_token +``` + +Creating the first account (which becomes the admin) requires that token, so +whoever reaches a newly exposed server first can't claim it. The token +changes on every restart until an account exists. + +## Android app + +The app connects over whatever URL you give it, HTTP or HTTPS. Once the +server has a public HTTPS address, give the app that address so the session +cookie never crosses the internet unencrypted. See +[security notes](./security.md#android-allows-plain-http) for why plain HTTP +is still allowed. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 00000000..fc6b030f --- /dev/null +++ b/docs/security.md @@ -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.