Files
Tobias GesellchenandClaude Sonnet 4.6 1dba7646b4 sec8: refactor zeroconf API to (host, port string) to close request-forgery alerts
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>
2026-05-25 13:14:56 +02:00

180 lines
4.8 KiB
Go

package amazon
import (
"encoding/base64"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/zeroconf"
)
func TestPushAmazonCredentials_FullRoundTrip(t *testing.T) {
speakerPrivate, speakerPublicBytes, err := zeroconf.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 := zeroconf.ComputeSharedSecret(speakerPrivate, clientKeyBytes)
encKey, macKey := zeroconf.DeriveKeys(shared)
plaintext, err := zeroconf.DecryptBlob(encKey, macKey, blobBytes)
if err != nil {
http.Error(w, "decrypt failed: "+err.Error(), http.StatusBadRequest)
return
}
// Minimal protobuf parse: field 1 = username, field 4 = authData, field 5 = authType
i := 0
for i < len(plaintext) {
tag := plaintext[i]
i++
fieldNum := tag >> 3
wireType := tag & 0x07
switch wireType {
case 0:
val, n := readVarint(plaintext[i:])
i += n
if fieldNum == 5 {
got.authType = int(val)
}
case 2:
length, n := readVarint(plaintext[i:])
i += n
value := plaintext[i : i+int(length)]
i += int(length)
switch fieldNum {
case 1:
got.username = string(value)
case 4:
got.authData = string(value)
}
default:
http.Error(w, "unexpected wire type", http.StatusBadRequest)
return
}
}
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
const wantUsername = "amazonuser@example.com"
const wantToken = "Atza|access-token"
host, port, _ := net.SplitHostPort(srv.Listener.Addr().String())
if err := PushAmazonCredentials(host, port, wantUsername, wantToken); err != nil {
t.Fatalf("PushAmazonCredentials: %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) != zeroconf.AuthTypeOAuthToken {
t.Errorf("authType = %d, want %d (AuthTypeOAuthToken)", got.authType, zeroconf.AuthTypeOAuthToken)
}
}
func TestPushAmazonCredentials_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 = "amazonuser@example.com"
const wantToken = "Atza|raw-access-token"
host, port, _ := net.SplitHostPort(srv.Listener.Addr().String())
if err := PushAmazonCredentials(host, port, wantUsername, wantToken); err != nil {
t.Fatalf("PushAmazonCredentials: %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"])
}
}
func readVarint(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)
}