Files
Bose-SoundTouch/pkg/service/zeroconf/zeroconf_test.go
T
Tobias GesellchenandClaude Opus 4.7 fbde4e136f fix(security): tighten zeroconf URL validation to literal local IPs
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>
2026-05-10 14:30:55 +02:00

366 lines
10 KiB
Go

package zeroconf
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestGenerateDHKeyPair(t *testing.T) {
priv1, pub1, err := GenerateDHKeyPair()
if err != nil {
t.Fatalf("GenerateDHKeyPair: %v", err)
}
if priv1 == nil || len(pub1) == 0 {
t.Fatal("expected non-nil private key and non-empty public key")
}
if len(pub1) != dhKeySize {
t.Errorf("public key length = %d, want %d", len(pub1), dhKeySize)
}
// Two calls must produce different key pairs.
_, pub2, err := GenerateDHKeyPair()
if err != nil {
t.Fatalf("GenerateDHKeyPair second call: %v", err)
}
if string(pub1) == string(pub2) {
t.Error("two key-pair generations produced identical public keys")
}
}
func TestDHCommutativity(t *testing.T) {
// DH shared secret must be symmetric: A's secret == B's secret.
privA, pubA, err := GenerateDHKeyPair()
if err != nil {
t.Fatal(err)
}
privB, pubB, err := GenerateDHKeyPair()
if err != nil {
t.Fatal(err)
}
secretA := ComputeSharedSecret(privA, pubB)
secretB := ComputeSharedSecret(privB, pubA)
if string(secretA) != string(secretB) {
t.Error("DH shared secrets are not equal (commutativity broken)")
}
}
func TestDeriveKeys(t *testing.T) {
sharedSecret := make([]byte, dhKeySize)
for i := range sharedSecret {
sharedSecret[i] = byte(i)
}
encKey, macKey := DeriveKeys(sharedSecret)
if len(encKey) != 16 {
t.Errorf("encKey length = %d, want 16", len(encKey))
}
if len(macKey) != 20 {
t.Errorf("macKey length = %d, want 20", len(macKey))
}
// Deterministic: same input → same output.
encKey2, macKey2 := DeriveKeys(sharedSecret)
if string(encKey) != string(encKey2) || string(macKey) != string(macKey2) {
t.Error("DeriveKeys is not deterministic")
}
// Different secrets → different keys.
other := make([]byte, dhKeySize)
encKeyOther, _ := DeriveKeys(other)
if string(encKey) == string(encKeyOther) {
t.Error("different secrets produced the same encKey")
}
}
func TestBuildCredentialsBlob(t *testing.T) {
blob := BuildCredentialsBlob("alice", "tok123", AuthTypeOAuthToken)
creds, err := parseCredentialsBlob(blob)
if err != nil {
t.Fatalf("parseCredentialsBlob: %v", err)
}
if creds.username != "alice" {
t.Errorf("username = %q, want %q", creds.username, "alice")
}
if string(creds.authData) != "tok123" {
t.Errorf("authData = %q, want %q", string(creds.authData), "tok123")
}
if uint64(creds.authType) != AuthTypeOAuthToken {
t.Errorf("authType = %d, want %d (AuthTypeOAuthToken)", creds.authType, AuthTypeOAuthToken)
}
}
func TestEncryptDecryptBlob(t *testing.T) {
sharedSecret := make([]byte, dhKeySize)
for i := range sharedSecret {
sharedSecret[i] = byte(42 + i)
}
encKey, macKey := DeriveKeys(sharedSecret)
plaintext := []byte("hello zeroconf world")
encrypted, err := EncryptBlob(encKey, macKey, plaintext)
if err != nil {
t.Fatalf("EncryptBlob: %v", err)
}
decrypted, err := DecryptBlob(encKey, macKey, encrypted)
if err != nil {
t.Fatalf("DecryptBlob: %v", err)
}
if string(decrypted) != string(plaintext) {
t.Errorf("round-trip mismatch: got %q, want %q", decrypted, plaintext)
}
// Tampered checksum must fail.
tampered := make([]byte, len(encrypted))
copy(tampered, encrypted)
tampered[len(tampered)-1] ^= 0xff
if _, err := DecryptBlob(encKey, macKey, tampered); err == nil {
t.Error("expected error on tampered checksum, got nil")
}
}
func TestPushCredentials_FullRoundTrip(t *testing.T) {
speakerPrivate, speakerPublicBytes, err := GenerateDHKeyPair()
if err != nil {
t.Fatalf("speaker keygen: %v", err)
}
type received struct {
username string
authData string
authType int
}
var got received
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("action") {
case "getInfo":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": 101,
"statusString": "OK",
"publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes),
})
case "addUser":
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
blobBytes, err := base64.StdEncoding.DecodeString(r.FormValue("blob"))
if err != nil {
http.Error(w, "bad blob base64: "+err.Error(), http.StatusBadRequest)
return
}
clientKeyBytes, err := base64.StdEncoding.DecodeString(r.FormValue("clientKey"))
if err != nil {
http.Error(w, "bad clientKey base64: "+err.Error(), http.StatusBadRequest)
return
}
shared := ComputeSharedSecret(speakerPrivate, clientKeyBytes)
encKey, macKey := DeriveKeys(shared)
plaintext, err := DecryptBlob(encKey, macKey, blobBytes)
if err != nil {
http.Error(w, "decrypt failed: "+err.Error(), http.StatusBadRequest)
return
}
creds, err := parseCredentialsBlob(plaintext)
if err != nil {
http.Error(w, "parse failed: "+err.Error(), http.StatusBadRequest)
return
}
got.username = creds.username
got.authData = string(creds.authData)
got.authType = creds.authType
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
const wantUsername = "user@example.com"
const wantToken = "eyJhbGciOiJSUzI1NiJ9.access-token"
if err := PushCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil {
t.Fatalf("PushCredentials: %v", err)
}
if got.username != wantUsername {
t.Errorf("username = %q, want %q", got.username, wantUsername)
}
if got.authData != wantToken {
t.Errorf("authData = %q, want %q", got.authData, wantToken)
}
if uint64(got.authType) != AuthTypeOAuthToken {
t.Errorf("authType = %d, want %d (AuthTypeOAuthToken)", got.authType, AuthTypeOAuthToken)
}
}
func TestPushCredentials_FallbackOnGetInfoFailure(t *testing.T) {
var receivedForm map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("action") {
case "getInfo":
http.Error(w, "not supported", http.StatusNotFound)
case "addUser":
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
receivedForm = map[string]string{
"userName": r.FormValue("userName"),
"blob": r.FormValue("blob"),
"clientKey": r.FormValue("clientKey"),
"tokenType": r.FormValue("tokenType"),
}
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
const wantUsername = "user@example.com"
const wantToken = "raw-access-token"
if err := PushCredentials(srv.URL+"/zc", wantUsername, wantToken); err != nil {
t.Fatalf("PushCredentials: %v", err)
}
if receivedForm == nil {
t.Fatal("addUser was never called")
}
if receivedForm["userName"] != wantUsername {
t.Errorf("userName = %q, want %q", receivedForm["userName"], wantUsername)
}
if receivedForm["blob"] != wantToken {
t.Errorf("blob = %q, want raw token %q", receivedForm["blob"], wantToken)
}
if receivedForm["tokenType"] != "accesstoken" {
t.Errorf("tokenType = %q, want %q", receivedForm["tokenType"], "accesstoken")
}
if receivedForm["clientKey"] != "" {
t.Errorf("clientKey = %q, want empty for simplified fallback", receivedForm["clientKey"])
}
}
// parseCredentialsBlob is the inverse of BuildCredentialsBlob, used in tests.
func parseCredentialsBlob(data []byte) (*parsedCredentials, error) {
var r parsedCredentials
i := 0
for i < len(data) {
tag := data[i]
i++
fieldNum := tag >> 3
wireType := tag & 0x07
switch wireType {
case 0: // varint
val, n := readProtoVarint(data[i:])
i += n
if fieldNum == 5 {
r.authType = int(val)
}
case 2: // length-delimited
length, n := readProtoVarint(data[i:])
i += n
value := data[i : i+int(length)]
i += int(length)
switch fieldNum {
case 1:
r.username = string(value)
case 4:
r.authData = value
}
default:
return nil, fmt.Errorf("unsupported wire type %d at offset %d", wireType, i-1)
}
}
return &r, nil
}
type parsedCredentials struct {
username string
authType int
authData []byte
}
func readProtoVarint(data []byte) (uint64, int) {
var val uint64
for i, b := range data {
val |= uint64(b&0x7f) << (7 * uint(i))
if b&0x80 == 0 {
return val, i + 1
}
}
return 0, len(data)
}
func TestValidateZcBaseURL(t *testing.T) {
cases := []struct {
name string
input string
wantOK bool
wantHost string // expected u.Host on success
wantPath string
}{
{"loopback", "http://127.0.0.1:8200/zc", true, "127.0.0.1:8200", "/zc"},
{"loopback no port", "http://127.0.0.1/zc", true, "127.0.0.1", "/zc"},
{"private 192", "http://192.168.1.10:8200/zc", true, "192.168.1.10:8200", "/zc"},
{"private 10", "http://10.0.0.5/zc", true, "10.0.0.5", "/zc"},
{"private 172", "http://172.16.0.1/zc", true, "172.16.0.1", "/zc"},
{"link-local v4", "http://169.254.10.20/zc", true, "169.254.10.20", "/zc"},
{"ipv6 loopback", "http://[::1]:8200/zc", true, "[::1]:8200", "/zc"},
{"ipv6 link-local", "http://[fe80::1]:8200/zc", true, "[fe80::1]:8200", "/zc"},
{"strips query", "http://192.168.1.10:8200/zc?foo=bar", true, "192.168.1.10:8200", "/zc"},
{"public IP rejected", "http://1.1.1.1/zc", false, "", ""},
{"public ipv6 rejected", "http://[2001:db8::1]/zc", false, "", ""},
{"hostname rejected", "http://myspeaker.local/zc", false, "", ""},
{"plain hostname rejected", "http://speaker/zc", false, "", ""},
{"ftp scheme rejected", "ftp://192.168.1.10/zc", false, "", ""},
{"file scheme rejected", "file:///etc/passwd", false, "", ""},
{"empty host rejected", "http:///zc", false, "", ""},
{"unparseable rejected", "::not a url::", false, "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := validateZcBaseURL(tc.input)
if tc.wantOK {
if err != nil {
t.Fatalf("validateZcBaseURL(%q) returned error %v, want success", tc.input, err)
}
if got.Host != tc.wantHost {
t.Errorf("Host = %q, want %q", got.Host, tc.wantHost)
}
if got.Path != tc.wantPath {
t.Errorf("Path = %q, want %q", got.Path, tc.wantPath)
}
if got.RawQuery != "" {
t.Errorf("RawQuery = %q, want empty (validator should strip query)", got.RawQuery)
}
} else if err == nil {
t.Errorf("validateZcBaseURL(%q) succeeded, want error", tc.input)
}
})
}
}