Skip to content

harden auth, playback, scanner and websocket core - #41

Merged
klabast merged 6 commits into
mainfrom
fix/core-hardening
Aug 28, 2026
Merged

harden auth, playback, scanner and websocket core#41
klabast merged 6 commits into
mainfrom
fix/core-hardening

Conversation

@klabast

@klabast klabast commented Aug 28, 2026

Copy link
Copy Markdown
Owner

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, -race clean 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, because library_access was 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 a userID and 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. GetSession treated any ResolveDevice error 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 distinguishes ErrDeviceNotFound (delete) from ErrDeviceUnreachable (keep).

auth

  • 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 new recovery path when no session exists, so tightening this can't lock an owner out.
  • 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. Resolved once at startup now.
  • reset codes are no longer logged in plaintext, the response no longer discloses the data dir, expired code files get cleaned up.
  • setup TOCTOU could create two admins; rate limiting on the credential endpoints; cookie Secure derived from the request; expired sessions swept by a job.
  • handler.go (947 lines) split by concern.

playback

  • playing on a second device never stopped the first — two devices played at once and nothing referenced the orphan any more.
  • a failed auto-advance wedged playback forever, logged at Debug.
  • session read-modify-write had no serialization: a volume change raced the 1 Hz poller and lost, then got broadcast back so the slider snapped. Per-user locking, ordered user→device.
  • the resolver held one global lock across MPD I/O with no timeouts, so one vanished host wedged playback for every user. Per-device locking + dial/command timeouts.
  • transfer-while-paused validated nothing; Previous ignored session state; Next never stopped the device at end of queue; connections and poller entries leaked; supportsVolume raced.
  • status codes were wrong across the board (everything 404, so "MPD down" read as "no session") and wrapped errors echoed MPD host addresses to clients.

scanner

  • a failed job update spun processPendingScans forever 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.
  • one corrupt audio file panicked the worker goroutine and killed the process, which then crash-looped on the same file. Nothing in the server recovered.
  • Stop() could return while the next job was starting, letting main close the DB mid-scan.
  • resumed scans double-counted (a progress bar reading 186%).
  • deleted files were never removed from the library or the FTS index. Removal is skipped when the walk couldn't read every path — an unmounted share is indistinguishable from a mass delete, and that mistake is unrecoverable.

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, and the progress throttle at the source is time-bounded instead of firing every 10 files.
  • targeted sends were dropped silently, so a playback handoff could report success while the target never got it.
  • no 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

  • deleted ~750 lines: library/scanner.go (a drifted duplicate of the real scanner, and the only reason library depended on websocket), ffmpeg/extractor.go, playback/repository.go, and a ScanLibrary stub whose tests asserted the no-op.
  • library scan --follow never followed — the loop broke unconditionally with the sleep commented out.
  • album sort fields had four definitions, library name/path rules five. One source each now.
  • the two library forms hardcoded English while every other form used paraglide.
  • gofmt across the tree (15 files were dirty).

verification

gofmt -l .        clean          npm run check   0 errors
go vet ./...      clean          npm run lint    pass
go test ./...     all pass       npm test        113 pass
go test -race     all pass       npm run build   pass

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.

CreateFirstAdmin is on the Repository interface, 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.

klabast and others added 6 commits August 28, 2026 08:25
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
klabast merged commit 66eedec into main Aug 28, 2026
5 checks passed
@klabast
klabast deleted the fix/core-hardening branch August 28, 2026 07:51
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant