Add validateZcPort alongside validateZcHost: the strconv.Atoi→Itoa
round-trip produces a sanitised integer string that CodeQL no longer
considers tainted, closing the remaining go/request-forgery findings
at zeroconf.go:263, :336, :413.
Also rejects clearly invalid inputs (non-numeric, out-of-range) that
would previously have produced a silently broken URL.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace validateZcBaseURL(zcBaseURL string) with:
- validateZcHost(host string) (net.IP, error) — validates literal IP
- buildZcBase(ip net.IP, port string) *url.URL — builds URL with literal /zc path
The key change: the URL path is now the string literal "/zc" everywhere,
never derived from user input. CodeQL's go/request-forgery model traces
taint through the Path field of a rebuilt URL; removing that field from
the taint chain closes alerts 134, 135, 136.
Public API changes:
zeroconf.GetInfo(host, port string)
zeroconf.PushCredentials(host, port, username, accessToken string)
spotify.ZeroConfGetInfo(host, port string)
spotify.PushSpotifyCredentials(host, port, username, accessToken string)
amazon.PushAmazonCredentials(host, port, username, accessToken string)
Callers in handlers/server.go already held host+port separately via
net.SplitHostPort; the zcURL construction is removed.
Tests updated throughout; TestValidateZcBaseURL renamed to
TestValidateZcHost and TestBuildZcBase added for the new helpers.
Closes CodeQL alerts 134, 135, 136 (go/request-forgery).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Completes the docs-tier RFC-5737 rollout by sweeping the remaining
192.168.1.x references that lived outside .md / .txt / test files:
- .env.example — active PREFERRED_DEVICES default + examples
- .github/ISSUE_TEMPLATE/*.yml + workflows — issue template + CI examples
- cmd/websocket-demo/main.go, doc.go — top-level docs
- examples/*/main.go (7 files) — example program comments
- pkg/client/client.go — godoc examples
- pkg/models/doc.go — package godoc
- pkg/service/{amazon,spotify,zeroconf}/zeroconf.go — godoc comments
- pkg/service/handlers/web/index.html — placeholder text in the UI
- scripts/prepare-release.sh — example invocations
- scripts/spotify/spotify-prime-speaker.sh — usage comment
- tests/integration/http-client/http-client.env.json — fixture IPs
Same mapping as the docs commit (136d24a): 192.168.1.X → 192.0.2.X
preserving the last octet.
One semantic carve-out: the three zeroconf `zcBaseURL` godoc comments
in pkg/service/{amazon,spotify,zeroconf}/zeroconf.go switched to
192.168.10.10 instead of the doc range, because validateZcBaseURL
only accepts RFC-1918 / loopback / link-local. The comment must show
a value the validator actually accepts — see the matching test fix
in 92f66a2 for the same reason.
go build ./... clean. go test ./... clean except the pre-existing
TestDocsConsistency (untracked DEVICE-LOCAL-INSTALL.md, unrelated).
golangci-lint run ./... — 0 issues after a gofmt fix on
examples/zone-slave-operations/main.go.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the .md/.txt sweep across all tracked _test.go, testdata XML,
and .http integration files. Test files are self-contained (producer
+ assertion in the same file), so the matched-pair swap stays green
under `go test ./...`.
Mapping applied:
192.168.178.[0-9]+ → 192.0.2.[same]
192.168.1.[0-9]+ → 192.0.2.[same]
Sound Machinechen → Living Room SoundTouch
A Sound Machine → Kitchen SoundTouch
A81B6A536A98 + case/separator variants → AABBCCDDEEFF (etc.)
A81B6A849D99 → AABBCCDDEE01
A81B6A849D88 → AABBCCDDEE03
A81B6A536A09 → AABBCCDDEE04
884AEAEEBD27 → AABBCCDDEE02
3230304 → 1000001
9569497 → 1000002
Two semantic fixes alongside the bulk swap:
- pkg/service/zeroconf/zeroconf_test.go: the "private 192" and
"strips query" cases pin acceptance of RFC-1918 192.168/16. They
must use a real 192.168 value; doc-range IPs would (correctly) be
rejected by validateZcBaseURL. Switched to 192.168.10.10 — generic
enough not to match any home LAN default, real enough for the
validator. Added a comment explaining why this single test still
carries a 192.168 literal.
- pkg/service/setup/setup_test.go: TestTestDNSRedirection mocks the
device's `od -An -tu1` byte output, which is space-separated
octets ("192 168 1 100"). My sed only matched the dot-separated
form, so the mock was returning the old IP while the test
assertions had moved to the doc range. Updated to " 192 0 2 100".
go build ./... clean. go test ./... clean (only TestDocsConsistency
remains failing, which is a pre-existing/untracked-file issue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
storePreset on the speaker was failing with "AddPreset - failed due to
invalid SourceID" because the watchdog priming path only pushed ZeroConf
credentials and never registered a SPOTIFY ConfiguredSource in marge.
PrimeDeviceWithSpotify now:
- resolves the device's paired account via live :8090/info
(margeAccountUUID), falling back to ServiceDeviceInfo.AccountID — same
order as setup.populateDeviceInfo;
- writes a SPOTIFY ConfiguredSource under that account (providerID=15,
BoseSecret as credential), mirroring bridgeSpotifyToMarge;
- POSTs `<updates><sourcesUpdated/></updates>` so the speaker re-fetches
its on-device Sources.xml from marge.
Also introduce zeroconf.ErrAddUserNoOp for the narrow firmware quirk
(404 + empty body on ?action=addUser when activeUser already matches).
Recognised only on that exact pattern; real 4xx/5xx still surface loudly
with full response details. Same treatment applied to Amazon priming.
Docs:
- new docs/concepts/spotify-overview.md anchors the topic (mental model,
streamingoauth.bose.com DNS gotcha, token lifecycle, clientId notes,
troubleshooting table);
- spotify-oauth.md drops the removed install-primer endpoint and the
on-device boot-primer install sections, adds /mgmt/spotify/prime;
- spotify-priming-strategy.md and MUSIC-SERVICES.md link to the
overview.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>