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:
@@ -34,6 +34,9 @@ Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz
|
|||||||
services:
|
services:
|
||||||
minstrel:
|
minstrel:
|
||||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||||
|
# Reachable from your LAN at http://<host>: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']
|
ports: ['4533:4533']
|
||||||
volumes:
|
volumes:
|
||||||
# Your music library. Point ./music at wherever your audio files
|
# Your music library. Point ./music at wherever your audio files
|
||||||
@@ -76,9 +79,9 @@ docker compose up -d
|
|||||||
|
|
||||||
## First run
|
## 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).
|
||||||
|
|
||||||
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
|
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
|
||||||
|
|
||||||
@@ -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).
|
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
|
## Configuration
|
||||||
|
|
||||||
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
||||||
|
|||||||
@@ -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://<host>: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.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user