mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
~30 remaining go/log-injection alerts share a common pattern: other
positional args in a log call are wrapped in sanitizeLog() but the
trailing 'err' value (via "%v") is not. CodeQL traces taint through
error chains back to the log.Printf call site itself.
Add sanitizeErr(err error) string to every affected package's
logutil.go (strips newlines from err.Error(), returns "<nil>" when
nil). Three packages had no logutil.go yet; new files added for
cmd/soundtouch-cli, cmd/websocket-demo, and examples.
Call-site changes (replace "%v, err" with "%s, sanitizeErr(err)" and
wrap any other unsanitised args in sanitizeLog):
pkg/client:
- websocket.go:42 DefaultLogger.Printf now pre-formats and sanitises
the entire message (all variadic args sanitised)
- websocket.go:445 err → sanitizeErr(err)
pkg/service/handlers:
- handlers_account_mgmt.go:44 err
- handlers_bmx_tunein.go:324,336 err (stationID already sanitised)
- handlers_marge.go:288,510 err (deviceID/account already done)
- handlers_mgmt.go:409,436,720 err
- handlers_setup.go:1345 session + err
- server.go:500 bind
- server.go:504,863,944,1029, err (deviceIP/accountID already done)
1164,1174
pkg/service/marge:
- marge.go:1469,1923 saveErr / err
pkg/service/setup:
- setup.go:1417,2316,2462 fmt.Printf — deviceIP / hostsContent / ip
pkg/service/stockholm:
- proxy.go:117 effectiveTarget.String() + err
pkg/service/zeroconf:
- zeroconf.go:312 err
pkg/service/proxy:
- recorder.go:403 err (task.path already sanitised)
pkg/service/datastore:
- datastore.go:940 werr (device already sanitised)
pkg/discovery:
- dns.go:72 strings.Join(derived)
- dns.go:503 d.upstreamDNS (fmt.Sprint of []string)
cmd/soundtouch-cli:
- cmd_events.go:571 VerboseLogger.Printf — pre-format + sanitise
- common.go:335 PrintError message
cmd/websocket-demo:
- main.go:576 VerboseLogger.Printf — pre-format + sanitise
examples:
- recording-filename-demo.go:79 err
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
632 lines
15 KiB
Go
632 lines
15 KiB
Go
package stockholm
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var blockedRequestHeaders = map[string]bool{
|
|
"access-control-request-headers": true,
|
|
"access-control-request-method": true,
|
|
"connection": true,
|
|
"content-length": true,
|
|
"cookie": true,
|
|
"forwarded": true,
|
|
"host": true,
|
|
"http2-settings": true,
|
|
"keep-alive": true,
|
|
"origin": true,
|
|
"proxy-authenticate": true,
|
|
"proxy-authorization": true,
|
|
"referer": true,
|
|
"sec-ch-ua": true,
|
|
"sec-ch-ua-mobile": true,
|
|
"sec-ch-ua-platform": true,
|
|
"sec-fetch-dest": true,
|
|
"sec-fetch-mode": true,
|
|
"sec-fetch-site": true,
|
|
"sec-fetch-user": true,
|
|
"te": true,
|
|
"trailer": true,
|
|
"transfer-encoding": true,
|
|
"upgrade": true,
|
|
"x-forwarded-for": true,
|
|
"x-forwarded-host": true,
|
|
"x-forwarded-port": true,
|
|
"x-forwarded-proto": true,
|
|
"x-real-ip": true,
|
|
"x-requested-with": true,
|
|
}
|
|
|
|
var blockedResponseHeaders = map[string]bool{
|
|
"access-control-allow-credentials": true,
|
|
"access-control-allow-headers": true,
|
|
"access-control-allow-methods": true,
|
|
"access-control-allow-origin": true,
|
|
"access-control-expose-headers": true,
|
|
"access-control-max-age": true,
|
|
"connection": true,
|
|
"content-length": true,
|
|
"keep-alive": true,
|
|
"proxy-authenticate": true,
|
|
"proxy-authorization": true,
|
|
"set-cookie": true,
|
|
"set-cookie2": true,
|
|
"te": true,
|
|
"trailer": true,
|
|
"transfer-encoding": true,
|
|
"upgrade": true,
|
|
}
|
|
|
|
var proxyHTTPClient = &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
|
return nil // follow redirects
|
|
},
|
|
}
|
|
|
|
// HandleProxy serves the /api/http-proxy endpoint.
|
|
func HandleProxy(w http.ResponseWriter, r *http.Request, cfg *Config, state *NativeState) {
|
|
encodedTarget := r.URL.Query().Get("url")
|
|
if encodedTarget == "" {
|
|
http.Error(w, "Missing url query parameter", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
decodedTarget, err := url.QueryUnescape(encodedTarget)
|
|
if err != nil {
|
|
http.Error(w, "Invalid proxy target encoding", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
target, err := url.Parse(decodedTarget)
|
|
if err != nil || target.Host == "" {
|
|
http.Error(w, "Invalid proxy target", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
scheme := strings.ToLower(target.Scheme)
|
|
if scheme != "http" && scheme != "https" {
|
|
http.Error(w, "Unsupported proxy target", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if isProxyLoop(r, target) {
|
|
http.Error(w, "Refusing to proxy proxy endpoint", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
http.Error(w, "Failed to read request body", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
effectiveTarget := applyOverrideURL(target, cfg, state)
|
|
|
|
resp, err := executeProxyRequest(r, effectiveTarget, body, cfg, state)
|
|
if err != nil {
|
|
log.Printf("[Stockholm proxy] %s %s failed: %s", r.Method, sanitizeLog(effectiveTarget.String()), sanitizeErr(err))
|
|
http.Error(w, "Proxy request failed", http.StatusBadGateway)
|
|
|
|
return
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
http.Error(w, "Failed to read proxy response", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
// Login retry logic
|
|
if isLoginRequest(r.Method, effectiveTarget) {
|
|
resp, respBody, effectiveTarget = handleLoginRetry(r, effectiveTarget, body, resp, respBody, cfg, state)
|
|
captureSuccessfulLogin(resp, respBody, state)
|
|
}
|
|
|
|
captureRefreshedToken(effectiveTarget, resp, cfg, state)
|
|
relayProxyResponse(w, r.Method, resp, respBody)
|
|
}
|
|
|
|
func executeProxyRequest(r *http.Request, target *url.URL, body []byte, cfg *Config, state *NativeState) (*http.Response, error) {
|
|
method := strings.ToUpper(r.Method)
|
|
|
|
var bodyReader io.Reader
|
|
if method != "GET" && method != "HEAD" && len(body) > 0 {
|
|
bodyReader = strings.NewReader(string(body))
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(), method, target.String(), bodyReader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Forward allowed request headers
|
|
for k, vals := range r.Header {
|
|
if blockedRequestHeaders[strings.ToLower(k)] {
|
|
continue
|
|
}
|
|
|
|
for _, v := range sanitizeHeaderValues(vals) {
|
|
req.Header.Add(k, v)
|
|
}
|
|
}
|
|
|
|
// Inject backend headers
|
|
injectBackendHeaders(req, target, cfg, state)
|
|
|
|
return proxyHTTPClient.Do(req)
|
|
}
|
|
|
|
func injectBackendHeaders(req *http.Request, target *url.URL, cfg *Config, state *NativeState) {
|
|
host := target.Hostname()
|
|
path := target.Path
|
|
|
|
if cfg.IsBmxTarget(host) {
|
|
injectIfMissing(req, "x-bmx-api-key", cfg.EncryptedBmxToken)
|
|
injectIfMissing(req, "x-software-version", cfg.AppVersion)
|
|
}
|
|
|
|
if cfg.IsMargeTarget(host, path) {
|
|
mediaType := cfg.MediaTypeForPath(path)
|
|
injectIfMissing(req, "Accept", mediaType)
|
|
injectIfMissing(req, "Content-Type", mediaType)
|
|
injectIfMissing(req, "ClientType", "SOUNDTOUCH_COMPUTER_APP")
|
|
injectIfMissing(req, "GUID", firstNonEmpty(state.Get("guid"), state.Get("deviceGuid")))
|
|
injectIfMissing(req, "version_NativeFrameVersion", state.Get("nativeFrameVersion"))
|
|
injectIfMissing(req, "version_StockholmVersion", cfg.AppVersion)
|
|
injectIfMissing(req, "version_ProtocolVersion", cfg.ProtocolVersion)
|
|
|
|
if cfg.MargeServerKeyHeader != "" && cfg.MargeServerKey != "" {
|
|
injectIfMissing(req, cfg.MargeServerKeyHeader, cfg.MargeServerKey)
|
|
}
|
|
|
|
if shouldInjectAuth(path) {
|
|
injectIfMissing(req, "Authorization", state.Get("margeAuthToken"))
|
|
}
|
|
}
|
|
}
|
|
|
|
func shouldInjectAuth(path string) bool {
|
|
p := strings.ToLower(path)
|
|
if strings.HasSuffix(p, "/streaming/account/login") {
|
|
return false
|
|
}
|
|
|
|
if p == "/streaming/account" || p == "/streaming/account/" {
|
|
return false
|
|
}
|
|
|
|
if strings.Contains(p, "/streaming/account/email/") && strings.HasSuffix(p, "/environment") {
|
|
return false
|
|
}
|
|
|
|
if strings.HasPrefix(p, "/customer/account/password/email/") {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func injectIfMissing(req *http.Request, name, value string) {
|
|
if value == "" {
|
|
return
|
|
}
|
|
|
|
if req.Header.Get(name) != "" {
|
|
return
|
|
}
|
|
|
|
req.Header.Set(name, value)
|
|
}
|
|
|
|
func sanitizeHeaderValues(vals []string) []string {
|
|
out := make([]string, 0, len(vals))
|
|
for _, v := range vals {
|
|
t := strings.TrimSpace(v)
|
|
if t == "" || strings.EqualFold(t, "null") || strings.EqualFold(t, "undefined") {
|
|
continue
|
|
}
|
|
|
|
out = append(out, t)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func handleLoginRetry(
|
|
r *http.Request,
|
|
target *url.URL,
|
|
body []byte,
|
|
resp *http.Response,
|
|
respBody []byte,
|
|
cfg *Config,
|
|
state *NativeState,
|
|
) (*http.Response, []byte, *url.URL) {
|
|
if extractXMLStatusCode(respBody) != "4033" {
|
|
return resp, respBody, target
|
|
}
|
|
|
|
email, password := parseLoginCredentials(body)
|
|
if email == "" {
|
|
return resp, respBody, target
|
|
}
|
|
|
|
env := fetchEnvironment(r, target, email, password, cfg, state)
|
|
if env == nil || env.streamingURL == "" {
|
|
return resp, respBody, target
|
|
}
|
|
|
|
state.PutMany(map[string]string{
|
|
"overrideMargeURL": normalizeBaseURL(env.streamingURL),
|
|
"overrideUpdateURL": normalizeBaseURL(env.updateURL),
|
|
})
|
|
|
|
retryTarget := buildURIFromBase(env.streamingURL, target.Path, target.RawQuery)
|
|
if retryTarget == nil {
|
|
return resp, respBody, target
|
|
}
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
retryResp, err := executeProxyRequest(r, retryTarget, body, cfg, state)
|
|
if err != nil {
|
|
log.Printf("[Stockholm proxy] Login retry failed: %v", err)
|
|
return resp, respBody, target
|
|
}
|
|
|
|
retryBody, err := io.ReadAll(retryResp.Body)
|
|
_ = retryResp.Body.Close()
|
|
|
|
if err != nil {
|
|
return resp, respBody, target
|
|
}
|
|
|
|
return retryResp, retryBody, retryTarget
|
|
}
|
|
|
|
func fetchEnvironment(r *http.Request, loginTarget *url.URL, email, password string, cfg *Config, state *NativeState) *environmentInfo {
|
|
// Build environment URL
|
|
prefix := margePathPrefix(loginTarget.Path)
|
|
envPath := prefix + "/streaming/account/email/" + url.PathEscape(email) + "/environment"
|
|
|
|
envTarget := buildURIFromBase(loginTarget.Scheme+"://"+loginTarget.Host+"/", envPath, "")
|
|
if envTarget == nil {
|
|
return nil
|
|
}
|
|
|
|
envReq, err := http.NewRequestWithContext(r.Context(), "GET", envTarget.String(), nil)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Copy allowed headers from original request
|
|
for k, vals := range r.Header {
|
|
if blockedRequestHeaders[strings.ToLower(k)] {
|
|
continue
|
|
}
|
|
|
|
for _, v := range sanitizeHeaderValues(vals) {
|
|
envReq.Header.Add(k, v)
|
|
}
|
|
}
|
|
|
|
raw := email + ":" + password
|
|
envReq.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(raw)))
|
|
injectBackendHeaders(envReq, envTarget, cfg, state)
|
|
|
|
envResp, err := proxyHTTPClient.Do(envReq)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
defer func() { _ = envResp.Body.Close() }()
|
|
|
|
if envResp.StatusCode != 200 {
|
|
return nil
|
|
}
|
|
|
|
body, _ := io.ReadAll(envResp.Body)
|
|
|
|
return extractEnvironment(body)
|
|
}
|
|
|
|
func captureSuccessfulLogin(resp *http.Response, body []byte, state *NativeState) {
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return
|
|
}
|
|
|
|
updates := make(map[string]string)
|
|
|
|
if accountID := extractXMLAccountID(body); accountID != "" {
|
|
updates["margeAccountID"] = accountID
|
|
}
|
|
|
|
if creds := resp.Header.Get("Credentials"); creds != "" {
|
|
updates["margeAuthToken"] = creds
|
|
}
|
|
|
|
if len(updates) > 0 {
|
|
state.PutMany(updates)
|
|
}
|
|
}
|
|
|
|
func captureRefreshedToken(target *url.URL, resp *http.Response, cfg *Config, state *NativeState) {
|
|
if target == nil || !cfg.IsMargeTarget(target.Hostname(), target.Path) {
|
|
return
|
|
}
|
|
|
|
if refreshed := resp.Header.Get("Refresh"); refreshed != "" {
|
|
state.Set("margeAuthToken", refreshed)
|
|
}
|
|
}
|
|
|
|
func relayProxyResponse(w http.ResponseWriter, method string, resp *http.Response, body []byte) {
|
|
for k, vals := range resp.Header {
|
|
kl := strings.ToLower(k)
|
|
if kl == "" || strings.HasPrefix(kl, ":") || blockedResponseHeaders[kl] {
|
|
continue
|
|
}
|
|
|
|
for _, v := range vals {
|
|
w.Header().Add(k, v)
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
|
|
bodyAllowed := method != "HEAD" && resp.StatusCode != 204 && resp.StatusCode != 304
|
|
if bodyAllowed {
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
|
}
|
|
|
|
w.WriteHeader(resp.StatusCode)
|
|
|
|
if bodyAllowed {
|
|
_, _ = w.Write(body)
|
|
}
|
|
}
|
|
|
|
func isLoginRequest(method string, target *url.URL) bool {
|
|
return strings.EqualFold(method, "POST") &&
|
|
target != nil &&
|
|
strings.HasSuffix(strings.ToLower(target.Path), "/streaming/account/login")
|
|
}
|
|
|
|
func isProxyLoop(r *http.Request, target *url.URL) bool {
|
|
if !strings.HasPrefix(target.Path, "/api/http-proxy") {
|
|
return false
|
|
}
|
|
|
|
host := target.Hostname()
|
|
targetPort := target.Port()
|
|
|
|
localHost, localPort, _ := net.SplitHostPort(r.Host)
|
|
if localHost == "" {
|
|
localHost = r.Host
|
|
}
|
|
|
|
if targetPort != "" && targetPort == localPort {
|
|
if strings.EqualFold(host, localHost) ||
|
|
strings.EqualFold(host, "localhost") ||
|
|
host == "127.0.0.1" || host == "::1" {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Also check forwarded headers
|
|
extHost := resolveExternalHost(r)
|
|
extPort := resolveExternalPort(r)
|
|
|
|
if extHost != "" && strings.EqualFold(host, extHost) {
|
|
tp, _ := parsePort(target.Port(), target.Scheme)
|
|
if tp == extPort {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func resolveExternalHost(r *http.Request) string {
|
|
if v := r.Header.Get("X-Forwarded-Host"); v != "" {
|
|
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
|
|
return v[:idx]
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
if v := r.Host; v != "" {
|
|
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
|
|
return v[:idx]
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func resolveExternalPort(r *http.Request) int {
|
|
if v := r.Header.Get("X-Forwarded-Port"); v != "" {
|
|
if p, err := parsePort(v, ""); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
|
|
if v := r.Header.Get("X-Forwarded-Host"); v != "" {
|
|
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
|
|
if p, err := parsePort(v[idx+1:], ""); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
}
|
|
|
|
if v := r.Host; v != "" {
|
|
if idx := strings.LastIndexByte(v, ':'); idx >= 0 {
|
|
if p, err := parsePort(v[idx+1:], ""); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
}
|
|
|
|
if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
|
|
return 443
|
|
}
|
|
|
|
return 80
|
|
}
|
|
|
|
func parsePort(portStr, scheme string) (int, error) {
|
|
if portStr != "" {
|
|
var p int
|
|
|
|
_, err := fmt.Sscanf(portStr, "%d", &p)
|
|
|
|
return p, err
|
|
}
|
|
|
|
switch strings.ToLower(scheme) {
|
|
case "https":
|
|
return 443, nil
|
|
case "http":
|
|
return 80, nil
|
|
}
|
|
|
|
return 0, fmt.Errorf("no port")
|
|
}
|
|
|
|
func applyOverrideURL(target *url.URL, cfg *Config, state *NativeState) *url.URL {
|
|
if !cfg.IsMargeTarget(target.Hostname(), target.Path) {
|
|
return target
|
|
}
|
|
|
|
override := normalizeBaseURL(state.Get("overrideMargeURL"))
|
|
if override == "" {
|
|
return target
|
|
}
|
|
|
|
result := buildURIFromBase(override, target.Path, target.RawQuery)
|
|
if result == nil {
|
|
return target
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func buildURIFromBase(base, path, query string) *url.URL {
|
|
base = normalizeBaseURL(base)
|
|
if base == "" {
|
|
return nil
|
|
}
|
|
|
|
u, err := url.Parse(base)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
u.Path = path
|
|
u.RawQuery = query
|
|
|
|
return u
|
|
}
|
|
|
|
func margePathPrefix(path string) string {
|
|
p := strings.ToLower(path)
|
|
|
|
idx := strings.Index(p, "/streaming/")
|
|
if idx <= 0 {
|
|
return ""
|
|
}
|
|
|
|
return path[:idx]
|
|
}
|
|
|
|
// XML helpers for login retry
|
|
|
|
type xmlStatusCode struct {
|
|
StatusCode string `xml:"status-code"`
|
|
}
|
|
|
|
type xmlAccountAttr struct {
|
|
ID string `xml:"id,attr"`
|
|
}
|
|
|
|
type xmlLoginBody struct {
|
|
Username string `xml:"username"`
|
|
Password string `xml:"password"`
|
|
}
|
|
|
|
type xmlEnvironment struct {
|
|
StreamingURL string `xml:"streamingURL"`
|
|
UpdateURL string `xml:"updateURL"`
|
|
}
|
|
|
|
type environmentInfo struct {
|
|
streamingURL string
|
|
updateURL string
|
|
}
|
|
|
|
func extractXMLStatusCode(body []byte) string {
|
|
var v xmlStatusCode
|
|
if err := xml.Unmarshal(body, &v); err == nil && v.StatusCode != "" {
|
|
return v.StatusCode
|
|
}
|
|
// Try finding in any wrapper element
|
|
type wrapper struct {
|
|
StatusCode string `xml:"status-code"`
|
|
}
|
|
|
|
var w wrapper
|
|
|
|
_ = xml.Unmarshal(body, &w)
|
|
|
|
return w.StatusCode
|
|
}
|
|
|
|
func parseLoginCredentials(body []byte) (email, password string) {
|
|
var login xmlLoginBody
|
|
if err := xml.Unmarshal(body, &login); err != nil {
|
|
return "", ""
|
|
}
|
|
|
|
return strings.TrimSpace(login.Username), login.Password
|
|
}
|
|
|
|
func extractXMLAccountID(body []byte) string {
|
|
type accountWrapper struct {
|
|
Account xmlAccountAttr `xml:"account"`
|
|
}
|
|
|
|
var w accountWrapper
|
|
if err := xml.Unmarshal(body, &w); err == nil && w.Account.ID != "" {
|
|
return w.Account.ID
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func extractEnvironment(body []byte) *environmentInfo {
|
|
var env xmlEnvironment
|
|
if err := xml.Unmarshal(body, &env); err != nil {
|
|
return nil
|
|
}
|
|
|
|
if env.StreamingURL == "" && env.UpdateURL == "" {
|
|
return nil
|
|
}
|
|
|
|
return &environmentInfo{streamingURL: env.StreamingURL, updateURL: env.UpdateURL}
|
|
}
|