harden auth, playback, scanner and websocket core - #41
Merged
Conversation
library/scanner.go had no callers and was a drifted duplicate of scanner/worker.go — it predated album-artist handling, mutated an unsynchronised lastBroadcast field, and handed GetProgress callers the live *ScanProgress the scan goroutine was writing. It was also the only reason library depended on websocket. ffmpeg/extractor.go and playback/repository.go likewise had no callers. service.ScanLibrary was a stub returning hardcoded zeros behind a TODO, with two tests asserting against the stub; StartScan is the real path. `library scan --follow` broke out of its poll loop unconditionally with the sleep commented out, so it never followed anything. gofmt across the tree — 15 files were dirty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes the findings from a subsystem review of the four core mechanics.
Every bug fix is guarded by a test that fails without it (14 new test
files). Full suite green, including -race on the concurrent packages.
auth / access control
- six library read routes had NO authentication: /libraries/{id}/albums,
/search, /scan-status, /albums/{id}, /cover, /tracks. The whole catalog
— titles, artists, track listings, cover art — was readable by anyone
who could reach the port, and the library_access ACL was bypassed
entirely by changing the path id. Authentication is now decided at
route registration (auth.RequireUser / RequireAdminUser) rather than
re-implemented per handler, and the read paths take a userID and check
access. A table-driven test walks every registered route.
- password changes revoked nothing: a stolen cookie survived the one
remediation a user knows to perform. All three password paths now drop
the user's other sessions.
- auth-disabled mode handed any caller on the network full admin over
user management, password reset and the auth toggle itself — and the
damage outlived the toggle. Privilege-granting surfaces now require a
real signed-in admin regardless of the setting. `audiod system auth on`
is the recovery path when no session exists.
- jwt secret: no length check (a truncated file meant an empty HMAC key
and forgeable stream tokens), any read error regenerated and destroyed
the existing secret, and log.Fatalf ran inside live requests. Now
resolved once at startup.
- reset codes are no longer logged in plaintext, the response no longer
discloses the data dir, and expired code files are cleaned up.
- setup TOCTOU could create two admins; rate limiting added to the
credential endpoints; session cookie Secure is derived from the
request; expired sessions are swept by a job.
playback
- an unreachable MPD device permanently destroyed the queue: GetSession
deleted on ANY resolver error, and a refused dial is not "device gone".
Only ErrDeviceNotFound orphans a session now.
- playing on a second device never stopped the first; a failed
auto-advance wedged playback forever; transfer-while-paused validated
nothing; Previous ignored session state; Next never stopped the device.
- session read-modify-write had no serialization, so a volume change
raced the 1Hz poller and lost. Per-user locking, ordered user→device.
- resolver held one global lock across MPD I/O with no timeouts, so one
vanished host wedged every user's playback; connections and poller
entries leaked; supportsVolume raced.
scanner
- a failed job update spun processPendingScans forever with no backoff,
monopolising the single SQLite connection and starving every request.
- one corrupt audio file panicked the worker goroutine and killed the
process, which then crash-looped on the same file.
- Stop() could return while the next job was starting, letting main
close the DB mid-scan.
- resumed scans double-counted progress (a bar reading 186%).
- deleted files were never removed from the library or the search index;
removal is skipped when the walk couldn't read every path, since an
unmounted share is indistinguishable from a mass delete.
websocket
- a full send buffer disconnected the client, so backgrounded tabs and
phones on weak links were kicked and reconnected for the length of
every scan. Slow readers now drop messages, not connections; the
progress throttle at the source is time-bounded.
- targeted sends were dropped silently, so a playback handoff could
report success while the target never received it.
- no SetReadLimit (unbounded allocation from any peer); handler
callbacks raced; a panic in a job or WS handler killed the process.
dead code and duplication
- album sort fields had four definitions; library name/path rules five.
One source each now, with the UI mirroring the server explicitly.
- the two library forms hardcoded English while every other form used
paraglide.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10 failed attempts per 15 minutes per IP is the right bound for one account, but the IP bucket aggregates every account behind one address — a household NAT, a reverse proxy — so one person mistyping a few times locked out the whole house. The per-account bucket is what actually protects an account and stays at 10; the IP bucket goes to 50, still far below what online guessing needs. Reset-code confirmation keeps the tight bound: it has no username to key on, so its IP bucket is the only limit there is. Split into its own scope so it doesn't inherit the widened one. Also fixes the e2e suite, which runs three device passes against one server from one address and accumulated ~12 credential failures across login, change-password and sudo scenarios — enough to trip the old shared limit on the last pass. E2E only runs post-merge, so this would have broken main rather than the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was an optional interface with a runtime type assertion, so a repository that didn't implement it silently fell back to the racy check-then-insert the conditional insert exists to replace. That's the worst shape for a security fix: the vulnerable path stays in the tree and nothing tells you which one you're on. It's on the Repository interface now, so a store either implements the atomic insert or doesn't compile. The three test doubles implement it with the same semantics as SQLite — succeed only while no user exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three patterns, all against comments.md: Restating the name. `// DeleteTracksByPaths removes tracks whose files are gone from disk` was also wrong — the method deletes tracks by path; "whose files are gone from disk" is the scanner's reason for calling it, not the method's contract. Same for clearSessionCookie and sessionContextFromRequest, which said nothing their signatures didn't. "Used by X." currentSessionID and clearSessionCookie listed their callers, which is the first thing to rot when a second caller appears. Narrating the bug that was fixed. "this used to be a second, subtly different probe", "six library read routes shipped without it", "an unknown username used to 500", "Scanning used to be add/update-only", "the generic 500 the read paths previously returned". None of it means anything to someone reading the file in six months, and the PR already says it. Where the mechanism was genuinely non-obvious — why persisting a paused session unwedges the poller, why an unreadable path must not be treated as a mass delete — the rule is stated in the present tense and the history is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Commit / UI failed with every test passing: 20 files, 113 tests green, then "Unhandled Error: document is not defined". Unmounting a bits-ui component that locks body scroll (the device selector's dropdown) schedules a cleanup 24ms later that touches document.body. When the file's last test unmounts and jsdom is torn down inside that window, the timer fires against a dead environment and vitest fails the run. It's a race with machine speed — it never reproduced locally over three full runs, and a plain CI re-run went green. Waiting out the timer in afterAll makes the outcome independent of how fast the box is. Pre-existing, not from this branch: the previous two runs here passed by luck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
klabast
added a commit
that referenced
this pull request
Aug 28, 2026
Follow-up to the comment pass in #41, applied to code that predates it. Zero-information doc comments ("SetLogger sets the structured logger"), a SendToClient comment listing today's two callers, and two history comments: PlayAlbumOnDevice explaining itself via "the bug that masked simultaneous browser+MPD playback", and ServeWs calling the hub-assigned counter "the old monotonic counter" while still using it as the live fallback. Left alone on purpose: the ~20 "NewX creates a new X" constructor comments. Equally empty, but they're the house convention and Go's own doc style — removing five and leaving fifteen is worse than leaving all twenty. Repo-wide style call. Comment-only; no behaviour change.
klabast
added a commit
that referenced
this pull request
Aug 28, 2026
pull_request runs the commit stage only; merge_group runs the full pipeline against trunk + the queued change (the gate); push to main runs it again and promotes. Fixes three gaps found while merging #40/#41/#42: acceptance only ran after merge, nothing tested the prospective combination, and promote was an unguarded race on :latest where last writer wins. ADR in docs/adr/0001-trunk-based-delivery-pipeline.md.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the findings from a review of the four core subsystems. Every bug fix is guarded by a test that fails without it — 14 new test files. Full suite green,
-raceclean on all concurrent packages.Stacked on nothing; branches off
main. #40 (deps) is independent.the two that matter most
The entire catalog was readable without authentication. Six library read routes —
/libraries/{id}/albums,/search,/scan-status,/albums/{id},/cover,/tracks— had no auth call at all. Anyone who could reach the port could enumerate album titles, artists, track listings and cover art; a logged-in user restricted to library 1 could read library 2 by changing the path id, becauselibrary_accesswas never consulted on those paths. Only the audio bytes were protected.Root cause was structural: there was no middleware, so every handler re-implemented its own check and protection was a thing you had to remember. Authentication is now decided at route registration (
auth.RequireUser/RequireAdminUser), the read paths take auserIDand check access, and a table-driven test walks every registered route so a new one can't ship open.An MPD device going offline permanently destroyed the user's queue.
GetSessiontreated anyResolveDeviceerror as "orphaned session, delete it" — but a refused TCP dial returns an ordinary error just like an unknown device. Amp reboots, next page load, queue/position/history gone from the DB for good. The resolver now distinguishesErrDeviceNotFound(delete) fromErrDeviceUnreachable(keep).auth
audiod system auth onis the new recovery path when no session exists, so tightening this can't lock an owner out.log.Fatalfran inside live requests. Resolved once at startup now.Securederived from the request; expired sessions swept by a job.handler.go(947 lines) split by concern.playback
Previousignored session state;Nextnever stopped the device at end of queue; connections and poller entries leaked;supportsVolumeraced.scanner
processPendingScansforever with no backoff and no stop check — and since the SQLite pool is pinned to one connection, that starved every HTTP request. Reproduced at ~69k calls in 1.5s.Stop()could return while the next job was starting, letting main close the DB mid-scan.websocket
SetReadLimit(unbounded allocation from any peer); handler callbacks raced; a panic in a scheduled job or WS handler killed the process.dead code and duplication
library/scanner.go(a drifted duplicate of the real scanner, and the only reasonlibrarydepended onwebsocket),ffmpeg/extractor.go,playback/repository.go, and aScanLibrarystub whose tests asserted the no-op.library scan --follownever followed — the loop broke unconditionally with the sleep commented out.gofmtacross the tree (15 files were dirty).verification
Behaviour changes worth knowing: playback status codes moved off blanket-404, and the password-reset response no longer returns
filePath(the UI now points at the data dir instead of printing the absolute path). Neither breaks the E2E suite — it reads reset codes off disk. Two orphaned step definitions that parsed the code out of the server log were removed, since the code is no longer logged.🤖 Generated with Claude Code
follow-ups in this branch
Rate limits are asymmetric, on purpose. 10 failures / 15 min is right for one account, but the per-IP bucket aggregates every account behind one address — a household NAT, a reverse proxy — so holding it to the same number locks out a whole house because one person mistyped. Per-account stays at 10; per-IP goes to 50 (still 200/hour against an unknown password). Reset-code confirmation keeps the tight bound in its own scope, since it has no username to key on and the code is the credential.
This also matters for E2E: three device passes run against one server from one address and accumulate ~12 credential failures across the login, change-password and sudo scenarios. Acceptance only runs post-merge, so the old shared limit would have broken
main, not this PR.CreateFirstAdminis on theRepositoryinterface, not an optional one behind a type assertion. As written, a store that didn't implement it silently fell back to the check-then-insert the atomic version exists to replace — the vulnerable path stayed in the tree with nothing indicating which one you were on. Now it either implements it or doesn't compile.