sec5e: sanitize log-injection in client, discovery, testutils, cmd

Fixes CodeQL go/log-injection alerts in the final batch of packages.

New logutil.go helpers: pkg/client, pkg/testutils/amazon,
pkg/testutils/spotify, cmd/soundtouch-service, cmd/soundtouch-web,
cmd/dummy-speaker, cmd/mdns-scanner.

pkg/discovery/logger.go: added sanitizeLog and a nil-safe
remoteAddrString helper to the existing file (alongside logVerbose).

Call sites wrapped across 11 files — device IDs, source types,
hostnames, IPs, interface names, URLs, service names, HTTP method/form
values, WebSocket URLs and payloads, TLS SNI names, remote addresses.

No behaviour change. golangci-lint and make check pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-24 17:29:39 +02:00
co-authored by Claude Sonnet 4.6
parent 3d8e08d11a
commit dc8ec69c61
18 changed files with 185 additions and 71 deletions
+13
View File
@@ -0,0 +1,13 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
+3 -3
View File
@@ -43,10 +43,10 @@ func main() {
log.Fatalf("start fake speaker: %v", err)
}
log.Printf("fake speaker HTTP listening on http://%s", s.HTTPAddr())
log.Printf("fake speaker HTTP listening on http://%s", sanitizeLog(s.HTTPAddr()))
if addr := s.TelnetAddr(); addr != "" {
log.Printf("fake speaker telnet listening on tcp://%s", addr)
log.Printf("fake speaker telnet listening on tcp://%s", sanitizeLog(addr))
}
if *register != "" {
@@ -58,7 +58,7 @@ func main() {
if err := registerWithService(*register, target); err != nil {
log.Printf("self-register failed: %v (continuing anyway)", err)
} else {
log.Printf("registered %s with service at %s", target, *register)
log.Printf("registered %s with service at %s", sanitizeLog(target), sanitizeLog(*register))
}
}
+13
View File
@@ -0,0 +1,13 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
+2 -2
View File
@@ -116,7 +116,7 @@ func main() {
defer close(entries)
if *verbose {
log.Printf("mDNS: Starting scan for service '%s' with timeout %v", *service, *timeout)
log.Printf("mDNS: Starting scan for service '%s' with timeout %v", sanitizeLog(*service), *timeout)
}
// Query for services
@@ -196,7 +196,7 @@ func parseServiceEntry(entry *mdns.ServiceEntry, verbose bool) *ServiceInfo {
if verbose {
log.Printf("mDNS: Received service entry: Name='%s', Host='%s', Port=%d, AddrV4=%v, AddrV6=%v",
entry.Name, entry.Host, entry.Port, entry.AddrV4, entry.AddrV6)
sanitizeLog(entry.Name), sanitizeLog(entry.Host), entry.Port, entry.AddrV4, entry.AddrV6)
}
service := &ServiceInfo{
+13
View File
@@ -0,0 +1,13 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
+20 -20
View File
@@ -71,7 +71,7 @@ func initializeDefaultSources(ds *datastore.DataStore) {
for i := range allDevices {
dev := &allDevices[i]
if sources, errGet := ds.GetConfiguredSources(dev.AccountID, dev.DeviceID); errGet == nil {
log.Printf("Initializing default Sources.xml for existing device %s", dev.DeviceID)
log.Printf("Initializing default Sources.xml for existing device %s", sanitizeLog(dev.DeviceID))
// Find default sources and merge them if missing or outdated tokens.
// claimed tracks which stored sources have already been matched by a default,
@@ -103,13 +103,13 @@ func initializeDefaultSources(ds *datastore.DataStore) {
claimed[foundIdx] = true
if sources[foundIdx].Secret == "" && def.Secret != "" {
log.Printf("Initializing missing token for source %s on device %s", def.SourceKeyType, dev.DeviceID)
log.Printf("Initializing missing token for source %s on device %s", sanitizeLog(def.SourceKeyType), sanitizeLog(dev.DeviceID))
sources[foundIdx].Secret = def.Secret
sources[foundIdx].SecretType = def.SecretType
modified = true
}
} else {
log.Printf("Adding missing default source %s (providerID=%s) to device %s", def.SourceKeyType, def.SourceProviderID, dev.DeviceID)
log.Printf("Adding missing default source %s (providerID=%s) to device %s", sanitizeLog(def.SourceKeyType), sanitizeLog(def.SourceProviderID), sanitizeLog(dev.DeviceID))
sources = append(sources, def)
modified = true
}
@@ -117,7 +117,7 @@ func initializeDefaultSources(ds *datastore.DataStore) {
if modified {
if errSave := ds.SaveConfiguredSources(dev.AccountID, dev.DeviceID, sources); errSave != nil {
log.Printf("Failed to save updated sources for %s: %v", dev.DeviceID, errSave)
log.Printf("Failed to save updated sources for %s: %v", sanitizeLog(dev.DeviceID), errSave)
}
}
}
@@ -147,7 +147,7 @@ func initMusicServices(config serviceConfig, server *handlers.Server) {
clientIDPrefix = clientIDPrefix[:8]
}
log.Printf("Spotify service initialized (client ID: %s...)", clientIDPrefix)
log.Printf("Spotify service initialized (client ID: %s...)", sanitizeLog(clientIDPrefix))
}
if config.amazonClientID != "" {
@@ -172,7 +172,7 @@ func initMusicServices(config serviceConfig, server *handlers.Server) {
clientIDPrefix = clientIDPrefix[:8]
}
log.Printf("Amazon Music service initialized (client ID: %s...)", clientIDPrefix)
log.Printf("Amazon Music service initialized (client ID: %s...)", sanitizeLog(clientIDPrefix))
}
}
@@ -188,7 +188,7 @@ func logBufferCapacityFromEnv(defaultCap int) int {
v, err := strconv.Atoi(raw)
if err != nil {
log.Printf("[Logs] Invalid SOUNDTOUCH_LOG_BUFFER_LINES=%q, using default %d", raw, defaultCap)
log.Printf("[Logs] Invalid SOUNDTOUCH_LOG_BUFFER_LINES=%q, using default %d", sanitizeLog(raw), defaultCap)
return defaultCap
}
@@ -415,7 +415,7 @@ func main() {
persisted := applyPersistedSettings(ds, &config)
if persisted.ServerURL == "" {
log.Printf("Creating default settings.json in %s", config.dataDir)
log.Printf("Creating default settings.json in %s", sanitizeLog(config.dataDir))
persisted = createDefaultSettings(ds, config)
}
@@ -468,7 +468,7 @@ func main() {
server.SetShortcuts(persisted.Shortcuts)
for path, status := range persisted.Shortcuts {
log.Printf("Warning: configured shortcut: %s -> %d", path, status)
log.Printf("Warning: configured shortcut: %s -> %d", sanitizeLog(path), status)
}
recorder := proxy.NewRecorder(config.dataDir)
@@ -477,11 +477,11 @@ func main() {
patterns, err := proxy.LoadPatterns(patternsPath)
if err != nil {
log.Printf("Warning: Failed to load patterns from %s: %v", patternsPath, err)
log.Printf("Warning: Failed to load patterns from %s: %v", sanitizeLog(patternsPath), err)
}
if len(patterns) == 0 {
log.Printf("Creating default patterns at %s", patternsPath)
log.Printf("Creating default patterns at %s", sanitizeLog(patternsPath))
patterns = proxy.DefaultPatterns()
@@ -512,17 +512,17 @@ func main() {
} else {
stockholmHandler = sh
log.Printf("Stockholm frontend enabled from %s", config.stockholmDir)
log.Printf("Stockholm frontend enabled from %s", sanitizeLog(config.stockholmDir))
}
}
r := setupRouter(server, stockholmHandler)
log.Printf("Go service starting on %s", config.serverURL)
log.Printf("Go service starting on %s", sanitizeLog(config.serverURL))
// TLS cert generation can be slow on constrained hardware; run it in the
// background so the HTTP server is available immediately.
log.Printf("HTTPS setup running in background; %s will be available shortly", config.httpsServerURL)
log.Printf("HTTPS setup running in background; %s will be available shortly", sanitizeLog(config.httpsServerURL))
go func() {
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
@@ -652,7 +652,7 @@ func loadConfig(c *cli.Context) serviceConfig {
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
if err != nil {
log.Printf("Warning: Failed to parse discovery interval %s, using default 5m: %v", discoveryIntervalStr, err)
log.Printf("Warning: Failed to parse discovery interval %s, using default 5m: %v", sanitizeLog(discoveryIntervalStr), err)
discoveryInterval = 5 * time.Minute
}
@@ -1294,7 +1294,7 @@ func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, h
return &tlsConfig.Certificates[0], nil
}
log.Printf("[TLS] ❌ No certificate available for %s", clientHello.ServerName)
log.Printf("[TLS] ❌ No certificate available for %s", sanitizeLog(clientHello.ServerName))
return nil, fmt.Errorf("no certificate available for %s", clientHello.ServerName)
}
@@ -1306,7 +1306,7 @@ func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, h
ErrorLog: log.Default(), // Ensure error logging is enabled
}
log.Printf("Go service starting HTTPS on %s", httpsServerURL)
log.Printf("Go service starting HTTPS on %s", sanitizeLog(httpsServerURL))
go func() {
listener, err := net.Listen("tcp", httpsAddr)
@@ -1361,9 +1361,9 @@ func runHTTPSPreflight(httpsServerURL, serverURL string, dnsEnabled bool, resolv
case res.Skipped:
// Listener already on :443 — nothing to say.
case res.NotApplicable:
log.Printf("HTTPS pre-flight: :443 check skipped — %s", res.Reason)
log.Printf("HTTPS pre-flight: :443 check skipped — %s", sanitizeLog(res.Reason))
default:
log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", res.LANHost)
log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", sanitizeLog(res.LANHost))
}
return
@@ -1438,7 +1438,7 @@ func (c *loggingTLSConn) Read(b []byte) (n int, err error) {
if strings.Contains(err.Error(), "tls:") ||
strings.Contains(err.Error(), "handshake") ||
strings.Contains(err.Error(), "certificate") {
log.Printf("[TLS] ❌ Handshake failed from %s: %v", c.addr, err)
log.Printf("[TLS] ❌ Handshake failed from %s: %v", sanitizeLog(c.addr.String()), err)
}
}
}
+13
View File
@@ -0,0 +1,13 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
+3 -3
View File
@@ -86,7 +86,7 @@ func main() {
}
if rawBind != "" && bindAddr != rawBind {
log.Printf("Resolved --bind %q to %s", rawBind, bindAddr)
log.Printf("Resolved --bind %q to %s", sanitizeLog(rawBind), sanitizeLog(bindAddr))
}
rawIface := c.String("interface")
@@ -94,7 +94,7 @@ func main() {
ifaceName := defaultDiscoveryInterface(rawIface, rawBind, bindAddr)
if rawIface == "" && ifaceName != "" {
log.Printf("Defaulting --interface to %q from --bind", ifaceName)
log.Printf("Defaulting --interface to %q from --bind", sanitizeLog(ifaceName))
}
addr := ":" + port
@@ -131,7 +131,7 @@ func main() {
r := chi.NewRouter()
webApp.Mount(r, discoveryService)
log.Printf("AfterTouch Web UI starting on http://%s", addr)
log.Printf("AfterTouch Web UI starting on http://%s", sanitizeLog(addr))
return http.ListenAndServe(addr, r)
},
+13
View File
@@ -0,0 +1,13 @@
package client
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
+4 -4
View File
@@ -218,7 +218,7 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error {
Path: "/",
}
ws.logger.Printf("Connecting to %s", wsURL.String())
ws.logger.Printf("Connecting to %s", sanitizeLog(wsURL.String()))
// Create dialer with custom buffer sizes and "gabbo" protocol
dialer := websocket.Dialer{
@@ -245,7 +245,7 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error {
go ws.readLoop(config)
go ws.pingLoop(config)
ws.logger.Printf("Connected to %s", wsURL.String())
ws.logger.Printf("Connected to %s", sanitizeLog(wsURL.String()))
return nil
}
@@ -443,7 +443,7 @@ func (ws *WebSocketClient) handleSpecialMessage(data []byte) {
if err != nil {
ws.logger.Printf("Unknown special message type: %v", err)
ws.logger.Printf("Raw message: %s", string(data))
ws.logger.Printf("Raw message: %s", sanitizeLog(string(data)))
return
}
@@ -589,7 +589,7 @@ func (ws *WebSocketClient) PairWithAccount(accountID, userAuthToken string) erro
return fmt.Errorf("failed to marshal pairing request: %w", err)
}
ws.logger.Printf("Sending PairDeviceWithAccount for account %s", accountID)
ws.logger.Printf("Sending PairDeviceWithAccount for account %s", sanitizeLog(accountID))
return ws.SendMessage(data)
}
+14 -14
View File
@@ -69,7 +69,7 @@ type DiscoveredHost struct {
func NewDNSDiscovery(upstreamDNS []string, serviceIP, serverURL string) *DNSDiscovery {
derived := DeriveOAuthHostnames(serverURL)
if len(derived) > 0 {
log.Printf("[DNS] Auto-hijacking OAuth subdomains derived from serverURL %q: %s", serverURL, strings.Join(derived, ", "))
log.Printf("[DNS] Auto-hijacking OAuth subdomains derived from serverURL %q: %s", sanitizeLog(serverURL), strings.Join(derived, ", "))
}
return &DNSDiscovery{
@@ -207,7 +207,7 @@ func (d *DNSDiscovery) recordQuery(hostname string, isIntercepted bool, remoteAd
d.discovered[hostname] = host
log.Printf("[NEW DISCOVERY] %s (Bose: %v, Intercepted: %v)",
hostname, host.IsBoseService, host.IsIntercepted)
sanitizeLog(hostname), host.IsBoseService, host.IsIntercepted)
if d.onNewDiscovery != nil {
go d.onNewDiscovery(hostname)
@@ -276,7 +276,7 @@ func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string
m.RecursionAvailable = true
q := r.Question[0]
log.Printf("[DNS] Intercepted query for %s (type %d) from %s", q.Name, q.Qtype, w.RemoteAddr())
log.Printf("[DNS] Intercepted query for %s (type %d) from %s", sanitizeLog(q.Name), q.Qtype, sanitizeLog(remoteAddrString(w)))
resolvedIP := ip
if net.ParseIP(ip) == nil {
@@ -312,9 +312,9 @@ func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string
if err == nil {
m.Answer = append(m.Answer, rr)
log.Printf("[DNS] Returning CNAME record %s -> %s", q.Name, target)
log.Printf("[DNS] Returning CNAME record %s -> %s", sanitizeLog(q.Name), sanitizeLog(target))
} else {
log.Printf("[DNS] Error creating CNAME fallback for %s: %v", target, err)
log.Printf("[DNS] Error creating CNAME fallback for %s: %v", sanitizeLog(target), err)
m.Rcode = dns.RcodeServerFailure
}
@@ -326,9 +326,9 @@ func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string
if err == nil {
m.Answer = append(m.Answer, rr)
log.Printf("[DNS] Returning A record %s -> %s", q.Name, resolvedIP)
log.Printf("[DNS] Returning A record %s -> %s", sanitizeLog(q.Name), sanitizeLog(resolvedIP))
} else {
log.Printf("[DNS] Error creating A record for %s: %v", resolvedIP, err)
log.Printf("[DNS] Error creating A record for %s: %v", sanitizeLog(resolvedIP), err)
m.Rcode = dns.RcodeServerFailure
}
@@ -340,15 +340,15 @@ func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string
if err == nil {
m.Answer = append(m.Answer, rr)
log.Printf("[DNS] Returning AAAA record %s -> %s", q.Name, resolvedIP)
log.Printf("[DNS] Returning AAAA record %s -> %s", sanitizeLog(q.Name), sanitizeLog(resolvedIP))
} else {
log.Printf("[DNS] Error creating AAAA record for %s: %v", resolvedIP, err)
log.Printf("[DNS] Error creating AAAA record for %s: %v", sanitizeLog(resolvedIP), err)
m.Rcode = dns.RcodeServerFailure
}
} else {
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues if no IPv6
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", sanitizeLog(q.Name))
}
default:
log.Printf("[DNS] Returning empty success for type %d", q.Qtype)
@@ -398,7 +398,7 @@ func (d *DNSDiscovery) forward(w dns.ResponseWriter, r *dns.Msg) {
if err == nil {
if in.Rcode == dns.RcodeSuccess {
if writeErr := w.WriteMsg(in); writeErr != nil {
log.Printf("[DNS ERROR] Failed to write forwarded response from %s: %v", upstream, writeErr)
log.Printf("[DNS ERROR] Failed to write forwarded response from %s: %v", sanitizeLog(upstream), writeErr)
}
return
@@ -485,7 +485,7 @@ func (d *DNSDiscovery) Start(addr string) error {
errChan := make(chan error, 2)
go func() {
log.Printf("[DNS] UDP Discovery server starting on %s", addr)
log.Printf("[DNS] UDP Discovery server starting on %s", sanitizeLog(addr))
if err := udpServer.ListenAndServe(); err != nil {
errChan <- fmt.Errorf("UDP server failed: %w", err)
@@ -493,14 +493,14 @@ func (d *DNSDiscovery) Start(addr string) error {
}()
go func() {
log.Printf("[DNS] TCP Discovery server starting on %s", addr)
log.Printf("[DNS] TCP Discovery server starting on %s", sanitizeLog(addr))
if err := tcpServer.ListenAndServe(); err != nil {
errChan <- fmt.Errorf("TCP server failed: %w", err)
}
}()
log.Printf("[DNS] Discovery servers starting on %s (upstream: %s, intercept IP: %s)", addr, d.upstreamDNS, d.serviceIP)
log.Printf("[DNS] Discovery servers starting on %s (upstream: %s, intercept IP: %s)", sanitizeLog(addr), d.upstreamDNS, sanitizeLog(d.serviceIP))
// Wait for first error
return <-errChan
+23
View File
@@ -2,6 +2,8 @@ package discovery
import (
"log"
"net"
"strings"
"sync/atomic"
)
@@ -38,3 +40,24 @@ func logVerbose(format string, args ...any) {
log.Printf(format, args...)
}
}
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
// remoteAddrString safely converts a net.Addr to a string, returning
// an empty string when addr is nil (dns.ResponseWriter.RemoteAddr may
// return nil in unit-test contexts).
func remoteAddrString(w interface{ RemoteAddr() net.Addr }) string {
if ra := w.RemoteAddr(); ra != nil {
return ra.String()
}
return ""
}
+5 -5
View File
@@ -143,7 +143,7 @@ func (m *MDNSDiscoveryService) queryService(service string, entries chan<- *mdns
return
}
log.Printf("mDNS: Query '%s' (IPv4) failed: %v — falling back to dual-stack", service, err)
log.Printf("mDNS: Query '%s' (IPv4) failed: %v — falling back to dual-stack", sanitizeLog(service), err)
err = mdns.Query(&mdns.QueryParam{
Service: service,
@@ -152,7 +152,7 @@ func (m *MDNSDiscoveryService) queryService(service string, entries chan<- *mdns
Entries: entries,
})
if err != nil {
log.Printf("mDNS: Query '%s' (dual-stack) failed: %v", service, err)
log.Printf("mDNS: Query '%s' (dual-stack) failed: %v", sanitizeLog(service), err)
} else {
logVerbose("mDNS: Query '%s' (dual-stack) completed successfully", service)
}
@@ -188,7 +188,7 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
ips, err := net.LookupIP(entry.Host)
if err != nil || len(ips) == 0 {
log.Printf("mDNS: Failed to resolve hostname '%s': %v", entry.Host, err)
log.Printf("mDNS: Failed to resolve hostname '%s': %v", sanitizeLog(entry.Host), err)
return nil
}
@@ -268,12 +268,12 @@ func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface {
if m.ifaceName != "" {
iface, err := net.InterfaceByName(m.ifaceName)
if err != nil {
log.Printf("mDNS: Configured interface %q not found: %v", m.ifaceName, err)
log.Printf("mDNS: Configured interface %q not found: %v", sanitizeLog(m.ifaceName), err)
return nil
}
if !interfaceHasIPv4(iface) {
log.Printf("mDNS: Configured interface %q has no usable IPv4 address", m.ifaceName)
log.Printf("mDNS: Configured interface %q has no usable IPv4 address", sanitizeLog(m.ifaceName))
return nil
}
+14 -14
View File
@@ -185,7 +185,7 @@ func (d *Service) PerformDiscovery(ctx context.Context) ([]*models.DiscoveredDev
log.Printf("UPnP: Discovery completed. Processed %d responses, found %d unique devices", responseCount, len(result))
for i, device := range result {
log.Printf("UPnP: Device #%d: %s at %s:%d (UPnP Location: %s)", i+1, device.Name, device.Host, device.Port, device.UPnPLocation)
log.Printf("UPnP: Device #%d: %s at %s:%d (UPnP Location: %s)", i+1, sanitizeLog(device.Name), sanitizeLog(device.Host), device.Port, sanitizeLog(device.UPnPLocation))
}
return result, nil
@@ -220,7 +220,7 @@ func (d *Service) setupUDPListener() (*net.UDPConn, error) {
// M-SEARCH leaves through the right NIC on multi-homed hosts.
if iface != nil {
if err := ipv4.NewPacketConn(listener).SetMulticastInterface(iface); err != nil {
log.Printf("UPnP: Failed to set multicast interface to %q: %v", iface.Name, err)
log.Printf("UPnP: Failed to set multicast interface to %q: %v", sanitizeLog(iface.Name), err)
// Continue regardless — the kernel will fall back to its own routing decision.
}
}
@@ -241,13 +241,13 @@ func (d *Service) resolveListenInterface() (net.IP, *net.Interface, error) {
iface, err := net.InterfaceByName(d.ifaceName)
if err != nil {
log.Printf("UPnP: Configured interface %q not found: %v", d.ifaceName, err)
log.Printf("UPnP: Configured interface %q not found: %v", sanitizeLog(d.ifaceName), err)
return nil, nil, fmt.Errorf("configured interface %q not found: %w", d.ifaceName, err)
}
addrs, err := iface.Addrs()
if err != nil {
log.Printf("UPnP: Failed to read addresses for interface %q: %v", d.ifaceName, err)
log.Printf("UPnP: Failed to read addresses for interface %q: %v", sanitizeLog(d.ifaceName), err)
return nil, nil, fmt.Errorf("read addresses for interface %q: %w", d.ifaceName, err)
}
@@ -262,12 +262,12 @@ func (d *Service) resolveListenInterface() (net.IP, *net.Interface, error) {
continue
}
log.Printf("UPnP: Binding UDP listener to interface %q (%s)", iface.Name, ipv4Addr)
log.Printf("UPnP: Binding UDP listener to interface %q (%s)", sanitizeLog(iface.Name), sanitizeLog(ipv4Addr.String()))
return ipv4Addr, iface, nil
}
log.Printf("UPnP: Configured interface %q has no usable IPv4 address", d.ifaceName)
log.Printf("UPnP: Configured interface %q has no usable IPv4 address", sanitizeLog(d.ifaceName))
return nil, nil, fmt.Errorf("interface %q has no usable IPv4 address", d.ifaceName)
}
@@ -326,7 +326,7 @@ func (d *Service) listenForResponses(ctx context.Context, listener *net.UDPConn,
device, err := d.parseResponse(responseText)
if err != nil {
log.Printf("UPnP: Failed to parse response #%d from %s: %v", responseCount, remoteAddr.String(), err)
log.Printf("UPnP: Failed to parse response #%d from %s: %v", responseCount, sanitizeLog(remoteAddr.String()), err)
continue // Skip invalid responses
}
@@ -380,7 +380,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
// Check if it's a valid HTTP response
if len(lines) < 1 || !strings.HasPrefix(lines[0], "HTTP/1.1 200") {
log.Printf("UPnP: Invalid HTTP response, first line: '%s'", lines[0])
log.Printf("UPnP: Invalid HTTP response, first line: '%s'", sanitizeLog(lines[0]))
return nil, fmt.Errorf("invalid HTTP response")
}
@@ -419,7 +419,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
// Accept both MediaRenderer and any device type for now - we'll validate it's a SoundTouch later
if !strings.Contains(strings.ToLower(st), "mediarenderer") && !strings.Contains(strings.ToLower(st), "upnp:rootdevice") {
log.Printf("UPnP: Device type '%s' is not a MediaRenderer, skipping", st)
log.Printf("UPnP: Device type '%s' is not a MediaRenderer, skipping", sanitizeLog(st))
return nil, fmt.Errorf("not a MediaRenderer device")
}
@@ -436,7 +436,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
// Extract device information from location URL
device, err := d.parseLocationURL(location, headers["usn"])
if err != nil {
log.Printf("UPnP: Failed to parse location URL '%s': %v", location, err)
log.Printf("UPnP: Failed to parse location URL '%s': %v", sanitizeLog(location), err)
return nil, fmt.Errorf("failed to parse location URL: %w", err)
}
@@ -449,7 +449,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
if err := d.EnrichDeviceInfo(device, location); err != nil {
logVerbose("UPnP: Could not enrich device info from location '%s': %v — accepting tentatively (will be re-verified by /info probe)", location, err)
} else if !isBoseUPnPDevice(device) {
log.Printf("UPnP: Rejecting non-Bose device: model=%q (manufacturer not Bose / model not SoundTouch)", device.ModelID)
log.Printf("UPnP: Rejecting non-Bose device: model=%q (manufacturer not Bose / model not SoundTouch)", sanitizeLog(device.ModelID))
return nil, fmt.Errorf("non-Bose UPnP device: %s", device.ModelID)
} else {
logVerbose("UPnP: Successfully enriched device info for %s (model=%q)", device.Name, device.ModelID)
@@ -495,7 +495,7 @@ func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevi
matches := re.FindStringSubmatch(location)
if len(matches) < 2 {
log.Printf("UPnP: Location URL '%s' does not match expected format http://host:port", location)
log.Printf("UPnP: Location URL '%s' does not match expected format http://host:port", sanitizeLog(location))
return nil, fmt.Errorf("invalid location URL format")
}
@@ -524,7 +524,7 @@ func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location str
resp, err := d.httpClient.Get(location)
if err != nil {
log.Printf("UPnP: Failed to fetch device description from %s: %v", location, err)
log.Printf("UPnP: Failed to fetch device description from %s: %v", sanitizeLog(location), err)
return err
}
@@ -554,7 +554,7 @@ func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location str
}
if err := xml.Unmarshal(data, &upnpRoot); err != nil {
log.Printf("UPnP: Failed to parse device description from %s: %v", location, err)
log.Printf("UPnP: Failed to parse device description from %s: %v", sanitizeLog(location), err)
return err
}
+3 -3
View File
@@ -23,7 +23,7 @@ func NewAmazonHandler() http.Handler {
// HandleToken simulates the Amazon LWA token endpoint.
// Amazon requires client_id and client_secret as POST body fields, not HTTP Basic Auth.
func HandleToken(w http.ResponseWriter, r *http.Request) {
log.Printf("[Amazon Mock] Token request: %s", r.Method)
log.Printf("[Amazon Mock] Token request: %s", sanitizeLog(r.Method))
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -36,7 +36,7 @@ func HandleToken(w http.ResponseWriter, r *http.Request) {
}
grantType := r.FormValue("grant_type")
log.Printf("[Amazon Mock] Grant type: %s", grantType)
log.Printf("[Amazon Mock] Grant type: %s", sanitizeLog(grantType))
resp := map[string]interface{}{
"access_token": "Atza|amazon-access-token",
@@ -71,7 +71,7 @@ func HandleToken(w http.ResponseWriter, r *http.Request) {
// HandleProfile simulates the Amazon LWA user profile endpoint.
// LWA returns "user_id" and "name" (not "id" / "display_name" like Spotify).
func HandleProfile(w http.ResponseWriter, r *http.Request) {
log.Printf("[Amazon Mock] Profile request: %s", r.Method)
log.Printf("[Amazon Mock] Profile request: %s", sanitizeLog(r.Method))
auth := r.Header.Get("Authorization")
if auth != "Bearer Atza|amazon-access-token" {
+13
View File
@@ -0,0 +1,13 @@
package amazon
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
+3 -3
View File
@@ -23,7 +23,7 @@ func NewSpotifyHandler() http.Handler {
// HandleToken simulates the Spotify OAuth token endpoint.
func HandleToken(w http.ResponseWriter, r *http.Request) {
log.Printf("[Spotify Mock] Token request: %s", r.Method)
log.Printf("[Spotify Mock] Token request: %s", sanitizeLog(r.Method))
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -36,7 +36,7 @@ func HandleToken(w http.ResponseWriter, r *http.Request) {
}
grantType := r.FormValue("grant_type")
log.Printf("[Spotify Mock] Grant type: %s", grantType)
log.Printf("[Spotify Mock] Grant type: %s", sanitizeLog(grantType))
resp := map[string]interface{}{
"access_token": "spotify-access-token",
@@ -73,7 +73,7 @@ func HandleToken(w http.ResponseWriter, r *http.Request) {
// HandleMe simulates the Spotify user profile endpoint.
func HandleMe(w http.ResponseWriter, r *http.Request) {
log.Printf("[Spotify Mock] Profile request: %s", r.Method)
log.Printf("[Spotify Mock] Profile request: %s", sanitizeLog(r.Method))
auth := r.Header.Get("Authorization")
if auth != "Bearer spotify-access-token" {
+13
View File
@@ -0,0 +1,13 @@
package spotify
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}