diff --git a/cmd/dummy-speaker/logutil.go b/cmd/dummy-speaker/logutil.go new file mode 100644 index 0000000..dda1fbc --- /dev/null +++ b/cmd/dummy-speaker/logutil.go @@ -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 +} diff --git a/cmd/dummy-speaker/main.go b/cmd/dummy-speaker/main.go index 82a75ab..5ca97a1 100644 --- a/cmd/dummy-speaker/main.go +++ b/cmd/dummy-speaker/main.go @@ -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)) } } diff --git a/cmd/mdns-scanner/logutil.go b/cmd/mdns-scanner/logutil.go new file mode 100644 index 0000000..dda1fbc --- /dev/null +++ b/cmd/mdns-scanner/logutil.go @@ -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 +} diff --git a/cmd/mdns-scanner/main.go b/cmd/mdns-scanner/main.go index 08a0d80..dd78fe9 100644 --- a/cmd/mdns-scanner/main.go +++ b/cmd/mdns-scanner/main.go @@ -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{ diff --git a/cmd/soundtouch-service/logutil.go b/cmd/soundtouch-service/logutil.go new file mode 100644 index 0000000..dda1fbc --- /dev/null +++ b/cmd/soundtouch-service/logutil.go @@ -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 +} diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 80a754e..62d5df7 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -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) } } } diff --git a/cmd/soundtouch-web/logutil.go b/cmd/soundtouch-web/logutil.go new file mode 100644 index 0000000..dda1fbc --- /dev/null +++ b/cmd/soundtouch-web/logutil.go @@ -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 +} diff --git a/cmd/soundtouch-web/main.go b/cmd/soundtouch-web/main.go index f1f7fdf..f97408e 100644 --- a/cmd/soundtouch-web/main.go +++ b/cmd/soundtouch-web/main.go @@ -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) }, diff --git a/pkg/client/logutil.go b/pkg/client/logutil.go new file mode 100644 index 0000000..7677c98 --- /dev/null +++ b/pkg/client/logutil.go @@ -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 +} diff --git a/pkg/client/websocket.go b/pkg/client/websocket.go index 8562c2b..046dc7e 100644 --- a/pkg/client/websocket.go +++ b/pkg/client/websocket.go @@ -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) } diff --git a/pkg/discovery/dns.go b/pkg/discovery/dns.go index ee4a99a..510ce5e 100644 --- a/pkg/discovery/dns.go +++ b/pkg/discovery/dns.go @@ -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 diff --git a/pkg/discovery/logger.go b/pkg/discovery/logger.go index 1dae8e8..83530f4 100644 --- a/pkg/discovery/logger.go +++ b/pkg/discovery/logger.go @@ -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 "" +} diff --git a/pkg/discovery/mdns.go b/pkg/discovery/mdns.go index 697370d..77fd803 100644 --- a/pkg/discovery/mdns.go +++ b/pkg/discovery/mdns.go @@ -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 } diff --git a/pkg/discovery/upnp.go b/pkg/discovery/upnp.go index 1988f10..d44eca6 100644 --- a/pkg/discovery/upnp.go +++ b/pkg/discovery/upnp.go @@ -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 } diff --git a/pkg/testutils/amazon/handlers.go b/pkg/testutils/amazon/handlers.go index 2539e8f..e64440a 100644 --- a/pkg/testutils/amazon/handlers.go +++ b/pkg/testutils/amazon/handlers.go @@ -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" { diff --git a/pkg/testutils/amazon/logutil.go b/pkg/testutils/amazon/logutil.go new file mode 100644 index 0000000..c6acc2f --- /dev/null +++ b/pkg/testutils/amazon/logutil.go @@ -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 +} diff --git a/pkg/testutils/spotify/handlers.go b/pkg/testutils/spotify/handlers.go index 1e64b49..86cd0e3 100644 --- a/pkg/testutils/spotify/handlers.go +++ b/pkg/testutils/spotify/handlers.go @@ -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" { diff --git a/pkg/testutils/spotify/logutil.go b/pkg/testutils/spotify/logutil.go new file mode 100644 index 0000000..3e172c5 --- /dev/null +++ b/pkg/testutils/spotify/logutil.go @@ -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 +}