Building on the strict literal-IP validator from the previous commit,
make the runtime error self-explanatory so anyone tripping on a
hostname URL can fix it in one shot:
* Errors now lead with the offending zeroconf URL and the rejected
host, so wrapping by GetInfo / PushCredentials / pushSimplifiedToken
doesn't bury the actual bad value.
* The "host must be a literal IP" error suggests two concrete one-liner
resolutions (`getent hosts <name>` and `dig +short <name>`) so the
user has a copy-paste fix.
* The "host is not on a local network" error names the accepted ranges
(loopback / RFC1918 private / link-local v4+v6) so the user knows
what they're allowed to pass.
docs/guides/SOUNDTOUCH-SERVICE.md gains a bullet under Security
Considerations explaining the constraint and the rationale (LAN-resident
SSRF surface), so the strict behaviour is documented rather than a
surprise.
The 17 TestValidateZcBaseURL cases still pass — only the message bodies
changed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeQL re-fired three new go/request-forgery alerts (#134/135/136) on
the lines my previous validateZcBaseURL refactor introduced. The
previous validator accepted hostname-style hosts unchanged, so even
though the IP-class check ran when applicable, u.String() at the call
sites still emitted the original tainted host into the request URL —
which is exactly what CodeQL traces.
Tighten validateZcBaseURL to:
* require the host to parse as a literal IP — DNS / mDNS hostnames
are rejected (with a clear error explaining the caller should
resolve to a private IP first); doing the lookup inside the
validator would re-introduce the SSRF surface CodeQL is flagging,
because malicious DNS could point a *.local name at a public host
between the lookup and the request.
* require that IP to be loopback / RFC1918 private / IPv4-or-IPv6
link-local. Anything else (global IPs in either family) is refused.
* rebuild the returned *url.URL from validated components — scheme
(already checked), the validated IP literal joined with the
original port, and the original path. Pre-existing query/fragment
are stripped so callers attach their own ?action= cleanly. CodeQL
recognises this fresh-construction pattern as taint sanitisation.
In practice this matches what SoundTouch speakers actually announce:
IP-based zeroconf URLs at port 8200 against an LAN address. The
existing PushCredentials_FullRoundTrip and FallbackOnGetInfoFailure
tests already exercise the loopback path through httptest.NewServer
and pass unchanged.
Adds TestValidateZcBaseURL covering 17 inputs — 9 accept (loopback,
private 10/172/192, link-local v4, IPv6 loopback, IPv6 link-local,
strips query) and 8 reject (public IPv4, public IPv6, hostname,
plain hostname, ftp/file schemes, empty host, unparseable) — to lock
the new contract in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeQL alerts #121, #122, #123 (go/request-forgery) flagged the three
client.Get / client.PostForm sites in pkg/service/zeroconf/zeroconf.go
that build their request URL by string-concatenating the caller-supplied
zcBaseURL with "?action=…". The base URL ultimately originates from a
device-pairing payload that the speaker pushes to us, so unvalidated
input could redirect outbound HTTP requests to arbitrary hosts (server-
side request forgery).
Add validateZcBaseURL which:
* parses zcBaseURL via net/url so the scheme and host are first-class
values rather than substrings,
* requires the scheme to be http or https,
* rejects literal IP hosts that aren't loopback / RFC1918 private /
link-local — those are the only places a real SoundTouch speaker
can live on a local network, and a global IP would be an obvious
exfiltration target,
* leaves hostname-style hosts (e.g. mDNS *.local) accepted: name
resolution itself is a separate trust boundary on the local segment.
A small withAction helper builds the per-call URL from the validated
base URL via url.Values rather than string concatenation, which CodeQL
recognises as a non-tainted construction.
GetInfo, PushCredentials and pushSimplifiedToken each call
validateZcBaseURL up-front so all three CodeQL alerts close in a
single pass. PushCredentials also re-validates even though it then
calls GetInfo (which validates again) so the fallback to
pushSimplifiedToken on getInfo failure is also gated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add GetAccountByRefreshToken to amazon.Service — the speaker sends
the bare Atzr| refresh token (extracted from AmazonSecret JSON), not
a surrogate, so lookup must match against Account.RefreshToken
- Add amazonService field, SetAmazonService and IsAmazonConfigured to
Server (step 5 essentials required by the handler)
- Replace HandleBoseAmazonToken 501 stub with full implementation:
lookup by refresh token → RefreshAccessToken; fallback to
GetFreshToken; fallback to HandleBoseProxy if no service configured;
scope intentionally omitted from response
- Add handler tests covering the by-refresh-token path (mock LWA
server), the default-account path, and the no-service fallback
- Unlock assertions in post_oauth_token_amazon.http integration test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract DH key exchange crypto from pkg/service/spotify into new
pkg/service/zeroconf package with exported functions and
AuthTypeOAuthToken constant (both Spotify and Amazon use auth type 4)
- Reduce pkg/service/spotify/zeroconf.go to thin wrappers around the
shared package; public API (PushSpotifyCredentials, ZeroConfGetInfo)
is preserved
- Add pkg/service/amazon package mirroring the Spotify service with
Amazon-specific differences: LWA endpoints, POST body credentials
(not Basic Auth), user_id/name profile fields, amazon/accounts.json
- Add PushAmazonCredentials delegating to shared zeroconf.PushCredentials
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>