## M462: Security hardening for public exposure (note #4807)
- Login, register, password-reset and Subsonic auth failures are throttled (#4976).
- The Secure cookie flag is set behind a TLS-terminating proxy (#4977).
- Sessions expire on the server, and a password change or reset ends the other sessions (#4978).
- Request bodies are capped, with private cache headers and JSON-only cookie writes (#4979).
- The web app sends security headers and a hash-based CSP (#4980).
- Reset links are built from an operator-set public address, never the Host header. **No reset emails are sent until that address is set** (#4981).
- The first account on a new server needs the setup token from the server log (#4982).
- Subsonic API keys are stored hashed. Existing keys are hashed in place and keep working (#4983).
- Known dependency vulnerabilities are cleared. One CI graph means nothing publishes on red, and govulncheck and npm audit now run (#4984).
- The Android session cookie is kept in Keystore-encrypted storage (#4985).
- Hosting and security docs (#4986).
- The Subsonic password is always generated and is never the login password. **Upgrading clears every stored Subsonic password once** (#5026).
## M464: Loudness normalization, steps 1–3
- Every track's loudness is measured in the background with ffmpeg ebur128. Settings and coverage are on the admin page (#4995).
- Album loudness is computed from the tracks' summed block histograms (#4996).
- Gains reach clients through the web lookup endpoint, the Android sync views and OpenSubsonic `replayGain` (#4997).
Migrations 0062–0066. CI is green on dev @ e3aa8629 (run 8348).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Every password-shaped check was mounted bare, so guessing was limited only
by bcrypt cost. A shared in-memory AttemptLimiter now sits in front of them:
- login: 10 failures per account and 50 per address per 15 min, checked
before the user lookup and bcrypt; 429 with Retry-After. A success clears
the account's count but not the address's.
- unknown usernames run a dummy bcrypt compare, so timing no longer says
which accounts exist.
- register: 10 per address per hour; forgot-password: 5 per address and 3
per email per hour (applied whether or not the email matches); reset: 20
failed tokens per address per 15 min.
- Subsonic /rest: same limits as login, counting only wrong credentials,
since clients authenticate on every request.
Web login, register, reset and forgot-password screens say how long to
wait; web and Android carry copy for the rate_limited code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cookie set Secure from r.TLS, which is nil whenever TLS terminates at
Traefik or Cloudflare, so a public deployment's session cookie went out
without Secure. auth.IsHTTPS now decides it from X-Forwarded-Proto, believed
only through the trusted-hop count (rule 94) and read positionally like
X-Forwarded-For. Plain-HTTP and LAN logins are unchanged; nothing redirects.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sessions had no server-side expiry: only the web cookie's 30-day Max-Age
limited them, and a bearer token (Android) lived until revoked by hand.
GetSessionByTokenHash and ListSessionsForUser now ignore sessions idle for
30 days or older than a year, and the GC worker deletes them hourly.
A password change was a plain UPDATE, so a session opened with the old
password survived it. Now:
- self-service change signs out every other device and keeps this one;
- reset by email ends every session the account has;
- an admin reset ends the target's sessions (keeping the admin's own when
they reset themselves).
The success copy on web and Android says the other devices were signed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Every request body is capped at 4 MiB and must arrive within 30s. The
deadline is set per request and cleared at end of body rather than via
http.Server.ReadTimeout, which would cancel audio streams and the SSE
stream once the background read hit it.
- IdleTimeout 120s closes idle keep-alive connections. Still no global
WriteTimeout, for the same streaming reason.
- Streams, album covers and playlist covers are Cache-Control: private, so
a shared cache never keeps an authenticated response for others.
- A cookie-authenticated write to /api must be application/json (415
otherwise). SameSite=Strict can't see a sibling app on the same
registrable domain; forms and no-preflight fetches can't send JSON.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There were no security headers at all. Now:
- every response: nosniff, Referrer-Policy strict-origin-when-cross-origin,
Permissions-Policy (no camera/mic/geolocation), X-Frame-Options DENY;
each set only if the handler hasn't.
- HSTS only when the trusted proxy reports HTTPS (rule 94); never a redirect.
- index.html carries a Content-Security-Policy whose script-src is 'self'
plus the sha256 of each inline script in the page as served, computed
after the branding template runs. No 'unsafe-inline' or 'unsafe-eval'
for scripts. img-src admits remote https/http because Lidarr suggestion
art is a remote poster URL.
Hashing in Go rather than via SvelteKit's kit.csp covers the inline scripts
SvelteKit doesn't know about (app.html's theme bootstrap and the branding
global injected at build) and stays correct whatever the app name is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
buildResetURL used r.Host and r.TLS, so a forgot-password request with a
forged Host emailed the victim a real reset token on a link to the
attacker's server. Links now come only from network_settings.public_url
(migration 0062), and no reset email is sent while it is empty; the response
stays the same opaque 200 and the log says why.
The address is set on a new "Public address" card under Admin → Integrations,
which offers the page's own origin and warns while unset. PUT
/api/admin/network-settings takes either field alone, so the proxy card and
this one can't overwrite each other.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While no accounts exist, the server mints a random setup token at boot and
logs it. Registering the first account (which becomes admin) must carry it,
so whoever reaches a freshly exposed instance first cannot claim it. The
register page asks GET /api/auth/setup-status and shows a "Setup token"
field in place of the invite field while setup is pending.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
users.api_token held each user's apiKey in plaintext and was looked up by
equality, so a leaked row or backup handed out working keys. Migration
0063 replaces it with api_token_hash (sha256, hex), computed in place
from the existing keys so every Subsonic client keeps working.
The key can no longer be read back: GET /api/me/api-token is gone, and
POST returns the new key once. Settings shows it right after Regenerate
with a copy button and a "won't be shown again" note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- golang.org/x/text v0.37.0 -> v0.39.0 (GO-2026-5970, infinite loop on
invalid input, reachable from pgxpool). x/sync follows to v0.21.0.
- web lockfile: in-range updates from `npm audit fix` for devalue (high)
and svelte (moderate), both of which ship in the browser bundle.
package.json is unchanged.
- Dockerfile builder golang:1.25 -> golang:1.26. CI has tested on 1.26
since the ci-go migration while the image was still compiled with 1.25,
left over from the April skeleton; the shipped binary now uses the
toolchain the tests ran on. govulncheck under golang:1.26-bookworm
(go1.26.8) reports 0 vulnerabilities reachable from our code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test-go, test-web and android were separate workflows on the same push as
release.yml, so the image build could not see their verdict: :dev meant
"it built", never "it passed". All lanes now live in release.yml, and
both publishing jobs (image-release and the new release-assets) need
every lane and require `result == 'success'` from each by name, so a
skipped lane blocks the publish just as a failed one does (rule 177).
- New lanes: govulncheck (in golang:1.26-bookworm, the builder's image,
so it checks the stdlib that ships) and `npm audit --omit=dev` in web.
- Attaching the APK to a Release moved out of android-release into
release-assets, behind the gate; the APK still builds in parallel.
- `docker buildx build --pull`, so floating base tags can't serve a
stale Go patch release from the runner's cache.
- Integration wait uses `pg_isready` via docker exec: the old /dev/tcp
probe never connects under dash (rule 81) and burned two minutes a run.
- workflow_dispatch input force_red fails the go lane on purpose, to
watch the gate refuse.
- release_gate_test.go pins the gate: every job must be classified, and
every publisher must need and require success from every lane.
Lanes have no path filters any more; a web-only push runs the Go suite
too, because "not run" must never read as "passed".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
actions/checkout runs on node, which golang:1.26-bookworm does not carry,
so the lane died at checkout (run 8272, exit 127) before scanning
anything. That run was also the gate's first red: every other lane
passed, and image-release and release-assets both skipped, leaving :dev
on the previous build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The session cookie is a bearer credential, and it sat in plain text in the
Room auth_session row. It now lives in a SessionVault: AES-256-GCM under a
key held in the Android Keystore, with only the ciphertext in a private
prefs file. A copy of the app's files no longer yields a usable session.
Platform APIs only, no new dependency (androidx.security-crypto is
deprecated).
Nobody is signed out by the upgrade. On first launch AuthStore moves a
cookie still in the row into the vault and clears the column. If the
Keystore can't be used on a device, the cookie stays in the row as before
rather than being lost. A sign-in or 401 that lands during the move wins
over the value it read, and the move never throws. The auth gate now
waits for this before choosing Login or Home, with a 10s deadline so a
wedged Keystore can't leave the start screen spinning.
Tests: AuthStoreSessionVaultTest (upgrade move, vault-only load, Keystore
fallback, sign-in/out, hydration race) and SealedBoxTest (round trip,
fresh IV, tamper and wrong-key rejection). The real Keystore path needs
a device; the first launch after updating is that check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
`minstrel admin reset-password` copied the new login password into
subsonic_password, which is stored in plain text because Subsonic t/s
sign-in needs it. Every account recovered through the CLI had its login
password readable in the database, and changing the password later left
the copy behind.
- reset-password now changes only password_hash.
- Migration 0064 clears every subsonic_password, removing the copies.
- Settings gets a Subsonic password card: the server generates a random
password, shows it once, and it can be regenerated or turned off
(GET/POST/DELETE /api/me/subsonic-password, audited). Generated rather
than user-chosen so it can never be a reused password.
- docs/security.md describes the separate password instead of the known
issue.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first step of loudness normalization: the server measures each track
with ffmpeg's EBU R128 filter (true peak, mono as dual mono) and stores the
integrated loudness, true peak and loudness range in track_loudness
(migration 0065).
It also keeps a histogram of the 400 ms gating blocks at 0.1 LU, so album
loudness can be computed exactly later with no second decode (#4996). The
histogram reproduces ffmpeg's own figure (-10.68 against -10.7 on the
captured fixture), and the analyzer logs a warning if the two ever drift.
- A background worker, cloned from the fingerprint backfill, measures every
track, new ones included. Measuring inline in the scan was dropped: the
analysis decodes the whole file, and a large import could pass the scan's
one-hour stuck threshold. The scan only deletes a changed file's
measurement; the worker ticks every 10 minutes.
- Timeouts, the cancel/missing-binary split and settled verdicts follow the
fingerprint runner. Silence and undecodable files are stored as verdicts;
stalls are retried. The deadline scales with track length.
- loudness_settings (enabled, files at once) and an admin card with the
coverage gauge, under GET/PUT /api/admin/library/loudness-settings and
GET /api/admin/library/loudness.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Album-mode normalization plays a whole album at one gain. That gain comes
from album_loudness (migration 0066): BS.1770's gated loudness over every
block on the album, computed by summing the tracks' stored histograms and
gating the sum. No audio is decoded again. Album true peak is the loudest
track's.
- Recomputed by the loudness worker each tick, after the track pass and
whether or not analysis is switched on. ListAlbumsNeedingLoudness lists
albums whose md5 over (present track id, measurement version and time) no
longer matches the stored digest. One comparison covers every way
membership changes (scan retag, duplicate merge, delete, missing and
restored) without hooking each.
- No album value until every present track has a settled measurement, so
an album's gain doesn't shift mid-listen as the rest is measured. Silent
and unreadable tracks count as settled.
- Rows for albums with no present track left are dropped.
- The parser and the merge share trimBins.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ReplayGain 2.0 values (gain to -18 LUFS, linear peak) derived from the
stored track and album loudness:
- Web: GET /api/tracks/replay-gain?ids=... (up to 200), a lookup the
player calls for its queue, rather than a field on every TrackRef
surface.
- Android: track_gain/track_peak and album_gain/album_peak on the sync
views, so cached tracks level offline. Storing a measurement logs a
track change, and an album's values moving logs an album change, both
before the write (#2704), so caches pick the gains up.
- OpenSubsonic: replayGain on every song (album, getSong, search3,
starred), as a JSON object and an XML element.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
M462: Security hardening for public exposure (note #4807)
M464: Loudness normalization, steps 1–3
replayGain(#4997).Migrations 0062–0066. CI is green on dev @
e3aa8629(run 8348).🤖 Generated with Claude Code