From 5e23325d683046e034b4f9ab580f3a22a61e6f4f Mon Sep 17 00:00:00 2001 From: Mohammed Aboullaite Date: Tue, 30 Jun 2026 09:51:10 +0200 Subject: [PATCH 1/3] Add support for additional backend headers via env var Allow host processes to pass extra headers to backend API requests through the SAVE_TO_SPOTIFY_HEADERS environment variable. The value is a JSON array of "Key:Value" strings. Only headers prefixed with X-STS- are accepted; all others are silently dropped. Headers are parsed once at startup and applied to every backend request via doAPIRequest. --- README.md | 1 + cmd/client.go | 3 +++ cmd/client_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + config/config.go | 40 +++++++++++++++++++++++++++++ config/config_test.go | 52 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 156 insertions(+) create mode 100644 cmd/client_test.go diff --git a/README.md b/README.md index a9d8cd0..88232eb 100644 --- a/README.md +++ b/README.md @@ -292,3 +292,4 @@ save-to-spotify list shows | `SAVE_TO_SPOTIFY_TIMEOUT` | API request timeout (e.g. `30s`, `2m`) | `30s` | | `SAVE_TO_SPOTIFY_CLIENT_ID` | OAuth client ID override | built-in | | `SAVE_TO_SPOTIFY_NO_UPDATE_CHECK` | Disable passive update checks | off | +| `SAVE_TO_SPOTIFY_HEADERS` | Additional headers for backend requests (JSON array of `"Key:Value"` strings; only `X-STS-*` headers are accepted) | — | diff --git a/cmd/client.go b/cmd/client.go index 5cb3560..1087125 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -47,6 +47,9 @@ func doAPIRequest(req *http.Request, token *config.TokenData) (*http.Response, e if req.Body != nil && req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json") } + for k, v := range config.AdditionalHeaders { + req.Header.Set(k, v) + } resp, err := httpClient.Do(req) if err != nil { diff --git a/cmd/client_test.go b/cmd/client_test.go new file mode 100644 index 0000000..86a6a04 --- /dev/null +++ b/cmd/client_test.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/spotify/save-to-spotify/config" +) + +func doTestAPIRequest(t *testing.T, headers map[string]string) http.Header { + t.Helper() + + orig := config.AdditionalHeaders + config.AdditionalHeaders = headers + t.Cleanup(func() { config.AdditionalHeaders = orig }) + + var gotHeaders http.Header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeaders = r.Header + w.WriteHeader(200) + })) + t.Cleanup(srv.Close) + + origURL := config.BackendBaseURL + config.BackendBaseURL = srv.URL + t.Cleanup(func() { config.BackendBaseURL = origURL }) + + req, _ := http.NewRequestWithContext(context.Background(), "GET", srv.URL+"/api/v1/shows", nil) + resp, err := doAPIRequest(req, &config.TokenData{AccessToken: "test-token"}) + if err != nil { + t.Fatalf("doAPIRequest: %v", err) + } + resp.Body.Close() + return gotHeaders +} + +func TestDoAPIRequest_NoAdditionalHeaders(t *testing.T) { + h := doTestAPIRequest(t, map[string]string{}) + + if got := h.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("Authorization = %q, want %q", got, "Bearer test-token") + } + if got := h.Get("X-STS-Test"); got != "" { + t.Errorf("X-STS-Test should be absent, got %q", got) + } +} + +func TestDoAPIRequest_WithAdditionalHeaders(t *testing.T) { + h := doTestAPIRequest(t, map[string]string{"X-STS-Test": "1"}) + + if got := h.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("Authorization = %q, want %q", got, "Bearer test-token") + } + if got := h.Get("X-STS-Test"); got != "1" { + t.Errorf("X-STS-Test = %q, want %q", got, "1") + } +} diff --git a/cmd/root.go b/cmd/root.go index bed43f8..5b1f8e1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -179,5 +179,6 @@ Environment variables: SAVE_TO_SPOTIFY_NO_UPDATE_CHECK Disable the passive update check that runs after successful commands SAVE_TO_SPOTIFY_RELEASES_URL Override the releases download URL SAVE_TO_SPOTIFY_RELEASES_API_URL Override the version check URL + SAVE_TO_SPOTIFY_HEADERS Additional backend headers (JSON array, X-STS-* only) `, binName) } diff --git a/config/config.go b/config/config.go index 7db6c29..2cd8aa4 100644 --- a/config/config.go +++ b/config/config.go @@ -285,3 +285,43 @@ func GetClientID() string { } return ClientID } + +const EnvVarHeaders = "SAVE_TO_SPOTIFY_HEADERS" + +// AdditionalHeaders holds extra HTTP headers to send on every backend API request. +// Parsed from SAVE_TO_SPOTIFY_HEADERS, a JSON array of "Key:Value" strings. +// Only headers with the X-STS- prefix are accepted; others are silently dropped. +var AdditionalHeaders = parseAdditionalHeaders() + +func parseAdditionalHeaders() map[string]string { + raw := os.Getenv(EnvVarHeaders) + if raw == "" { + return nil + } + + var entries []string + if err := json.Unmarshal([]byte(raw), &entries); err != nil { + return nil + } + + headers := make(map[string]string) + for _, entry := range entries { + key, val, ok := strings.Cut(entry, ":") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + if key == "" || val == "" { + continue + } + if !strings.HasPrefix(key, "X-STS-") { + continue + } + headers[key] = val + } + if len(headers) == 0 { + return nil + } + return headers +} diff --git a/config/config_test.go b/config/config_test.go index 166f39f..ddef4dc 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -181,3 +181,55 @@ func TestDPoPKeyPath(t *testing.T) { t.Errorf("DPoPKeyPath = %q, want %q", path, want) } } + +func setHeadersEnv(t *testing.T, val string) { + t.Helper() + t.Setenv(EnvVarHeaders, val) + orig := AdditionalHeaders + AdditionalHeaders = parseAdditionalHeaders() + t.Cleanup(func() { AdditionalHeaders = orig }) +} + +func TestParseAdditionalHeaders(t *testing.T) { + setHeadersEnv(t, `["X-STS-Test:true","X-STS-Foo:bar"]`) + + if got := AdditionalHeaders["X-STS-Test"]; got != "true" { + t.Errorf("X-STS-Test = %q, want %q", got, "true") + } + if got := AdditionalHeaders["X-STS-Foo"]; got != "bar" { + t.Errorf("X-STS-Foo = %q, want %q", got, "bar") + } +} + +func TestParseAdditionalHeaders_Empty(t *testing.T) { + setHeadersEnv(t, "") + + if AdditionalHeaders != nil { + t.Errorf("expected nil, got %v", AdditionalHeaders) + } +} + +func TestParseAdditionalHeaders_InvalidJSON(t *testing.T) { + setHeadersEnv(t, "not json") + + if AdditionalHeaders != nil { + t.Errorf("expected nil for invalid JSON, got %v", AdditionalHeaders) + } +} + +func TestParseAdditionalHeaders_RejectsNonSTS(t *testing.T) { + setHeadersEnv(t, `["X-STS-Test:true","X-Custom:bad","Authorization:evil"]`) + + if got := AdditionalHeaders["X-STS-Test"]; got != "true" { + t.Errorf("X-STS-Test = %q, want %q", got, "true") + } + if _, ok := AdditionalHeaders["X-Custom"]; ok { + t.Error("X-Custom should have been rejected") + } + if _, ok := AdditionalHeaders["Authorization"]; ok { + t.Error("Authorization should have been rejected") + } + if len(AdditionalHeaders) != 1 { + t.Errorf("expected 1 header, got %d: %v", len(AdditionalHeaders), AdditionalHeaders) + } +} From 35c96f848593ba8c7fc7a895bc6cffd592f45748 Mon Sep 17 00:00:00 2001 From: Mohammed Aboullaite Date: Thu, 2 Jul 2026 10:47:40 +0200 Subject: [PATCH 2/3] Harden additional backend headers: validate, canonicalize, scope to backend host --- README.md | 2 +- cmd/client.go | 17 +++++--- cmd/client_test.go | 69 +++++++++++++++++++++++++------- cmd/root.go | 2 +- cmd/update.go | 3 +- config/config.go | 86 ++++++++++++++++++++++++++++++++------- config/config_test.go | 89 +++++++++++++++++++++++++++++++---------- internal/httpx/httpx.go | 48 +++++++++++++++++++++- 8 files changed, 255 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 88232eb..ee1d3ea 100644 --- a/README.md +++ b/README.md @@ -292,4 +292,4 @@ save-to-spotify list shows | `SAVE_TO_SPOTIFY_TIMEOUT` | API request timeout (e.g. `30s`, `2m`) | `30s` | | `SAVE_TO_SPOTIFY_CLIENT_ID` | OAuth client ID override | built-in | | `SAVE_TO_SPOTIFY_NO_UPDATE_CHECK` | Disable passive update checks | off | -| `SAVE_TO_SPOTIFY_HEADERS` | Additional headers for backend requests (JSON array of `"Key:Value"` strings; only `X-STS-*` headers are accepted) | — | +| `SAVE_TO_SPOTIFY_HEADERS` | Additional headers for backend requests (JSON object of header name/value pairs; only `X-STS-*` headers are accepted) | — | diff --git a/cmd/client.go b/cmd/client.go index 1087125..d19a47c 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -18,12 +18,22 @@ func cliUserAgent() string { return fmt.Sprintf("%s/%s %s/%s", binName, uaVersion, runtime.GOOS, runtime.GOARCH) } +// backendTransport applies the CLI User-Agent to all requests and the +// additional backend headers (config.AdditionalHeaders) to requests targeting +// the backend host, delegating to http.DefaultTransport. +func backendTransport() http.RoundTripper { + return httpx.BackendHeadersTransport{ + Base: httpx.UserAgentTransport{UserAgent: cliUserAgent()}, + Headers: config.AdditionalHeaders, + BackendURL: func() string { return config.BackendBaseURL }, + } +} + // httpClient is the shared HTTP client // Its Timeout is set in Execute() after flag parsing (default: 30s, override via --timeout or config.EnvVarTimeout). -// The transport applies the CLI User-Agent while still delegating to http.DefaultTransport by default. var httpClient = &http.Client{ Timeout: config.APITimeout(), - Transport: httpx.UserAgentTransport{UserAgent: cliUserAgent()}, + Transport: backendTransport(), } // uploadClient is used for signed GCS PUTs. No timeout — large files can take many minutes. @@ -47,9 +57,6 @@ func doAPIRequest(req *http.Request, token *config.TokenData) (*http.Response, e if req.Body != nil && req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json") } - for k, v := range config.AdditionalHeaders { - req.Header.Set(k, v) - } resp, err := httpClient.Do(req) if err != nil { diff --git a/cmd/client_test.go b/cmd/client_test.go index 86a6a04..be8635c 100644 --- a/cmd/client_test.go +++ b/cmd/client_test.go @@ -9,13 +9,12 @@ import ( "github.com/spotify/save-to-spotify/config" ) -func doTestAPIRequest(t *testing.T, headers map[string]string) http.Header { +// startBackendTestServer starts a test server that records request headers. +// If asBackend is true, config.BackendBaseURL is pointed at it so the +// additional-headers transport treats it as the backend host. +func startBackendTestServer(t *testing.T, asBackend bool) (*httptest.Server, *http.Header) { t.Helper() - orig := config.AdditionalHeaders - config.AdditionalHeaders = headers - t.Cleanup(func() { config.AdditionalHeaders = orig }) - var gotHeaders http.Header srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotHeaders = r.Header @@ -23,37 +22,77 @@ func doTestAPIRequest(t *testing.T, headers map[string]string) http.Header { })) t.Cleanup(srv.Close) - origURL := config.BackendBaseURL - config.BackendBaseURL = srv.URL - t.Cleanup(func() { config.BackendBaseURL = origURL }) + if asBackend { + origURL := config.BackendBaseURL + config.BackendBaseURL = srv.URL + t.Cleanup(func() { config.BackendBaseURL = origURL }) + } + return srv, &gotHeaders +} + +func setAdditionalHeaders(t *testing.T, headers map[string]string) { + t.Helper() + orig := config.AdditionalHeaders() + config.SetAdditionalHeaders(headers) + t.Cleanup(func() { config.SetAdditionalHeaders(orig) }) +} + +func doTestAPIRequest(t *testing.T, srv *httptest.Server) { + t.Helper() req, _ := http.NewRequestWithContext(context.Background(), "GET", srv.URL+"/api/v1/shows", nil) resp, err := doAPIRequest(req, &config.TokenData{AccessToken: "test-token"}) if err != nil { t.Fatalf("doAPIRequest: %v", err) } resp.Body.Close() - return gotHeaders } func TestDoAPIRequest_NoAdditionalHeaders(t *testing.T) { - h := doTestAPIRequest(t, map[string]string{}) + setAdditionalHeaders(t, nil) + srv, gotHeaders := startBackendTestServer(t, true) + doTestAPIRequest(t, srv) - if got := h.Get("Authorization"); got != "Bearer test-token" { + if got := gotHeaders.Get("Authorization"); got != "Bearer test-token" { t.Errorf("Authorization = %q, want %q", got, "Bearer test-token") } - if got := h.Get("X-STS-Test"); got != "" { + if got := gotHeaders.Get("X-STS-Test"); got != "" { t.Errorf("X-STS-Test should be absent, got %q", got) } } func TestDoAPIRequest_WithAdditionalHeaders(t *testing.T) { - h := doTestAPIRequest(t, map[string]string{"X-STS-Test": "1"}) + setAdditionalHeaders(t, map[string]string{"X-STS-Test": "1"}) + srv, gotHeaders := startBackendTestServer(t, true) + doTestAPIRequest(t, srv) - if got := h.Get("Authorization"); got != "Bearer test-token" { + if got := gotHeaders.Get("Authorization"); got != "Bearer test-token" { t.Errorf("Authorization = %q, want %q", got, "Bearer test-token") } - if got := h.Get("X-STS-Test"); got != "1" { + if got := gotHeaders.Get("X-STS-Test"); got != "1" { t.Errorf("X-STS-Test = %q, want %q", got, "1") } } + +func TestDoAPIRequest_HeadersScopedToBackendHost(t *testing.T) { + setAdditionalHeaders(t, map[string]string{"X-STS-Test": "1"}) + srv, gotHeaders := startBackendTestServer(t, false) + doTestAPIRequest(t, srv) + + if got := gotHeaders.Get("X-STS-Test"); got != "" { + t.Errorf("X-STS-Test should not be sent to non-backend host, got %q", got) + } +} + +func TestDoAPIRequest_InvalidHeaderValueDoesNotBreakRequests(t *testing.T) { + setAdditionalHeaders(t, map[string]string{"X-STS-Bad": "a\nb", "X-STS-Ok": "1"}) + srv, gotHeaders := startBackendTestServer(t, true) + doTestAPIRequest(t, srv) + + if got := gotHeaders.Get("X-STS-Bad"); got != "" { + t.Errorf("X-STS-Bad should have been dropped, got %q", got) + } + if got := gotHeaders.Get("X-STS-Ok"); got != "1" { + t.Errorf("X-STS-Ok = %q, want %q", got, "1") + } +} diff --git a/cmd/root.go b/cmd/root.go index 5b1f8e1..0c5fcda 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -179,6 +179,6 @@ Environment variables: SAVE_TO_SPOTIFY_NO_UPDATE_CHECK Disable the passive update check that runs after successful commands SAVE_TO_SPOTIFY_RELEASES_URL Override the releases download URL SAVE_TO_SPOTIFY_RELEASES_API_URL Override the version check URL - SAVE_TO_SPOTIFY_HEADERS Additional backend headers (JSON array, X-STS-* only) + SAVE_TO_SPOTIFY_HEADERS Additional backend headers (JSON object, X-STS-* only) `, binName) } diff --git a/cmd/update.go b/cmd/update.go index b5dce11..3f049b2 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -19,7 +19,6 @@ import ( "time" "github.com/spotify/save-to-spotify/config" - "github.com/spotify/save-to-spotify/internal/httpx" ) var ( @@ -148,7 +147,7 @@ func fetchLatestVersion() (latestReleaseResponse, error) { resp, err := (&http.Client{ Timeout: releaseMetadataTimeout, - Transport: httpx.UserAgentTransport{UserAgent: cliUserAgent()}, + Transport: backendTransport(), }).Do(req) if err != nil { return latestReleaseResponse{}, fmt.Errorf("failed to check for updates: %w", err) diff --git a/config/config.go b/config/config.go index 2cd8aa4..75e798d 100644 --- a/config/config.go +++ b/config/config.go @@ -5,9 +5,11 @@ import ( "errors" "fmt" "io/fs" + "net/textproto" "net/url" "os" "path/filepath" + "sort" "strings" "time" ) @@ -23,6 +25,7 @@ const ( EnvVarClientID = "SAVE_TO_SPOTIFY_CLIENT_ID" EnvVarNoUpdateCheck = "SAVE_TO_SPOTIFY_NO_UPDATE_CHECK" EnvVarReleasesAPIURL = "SAVE_TO_SPOTIFY_RELEASES_API_URL" + EnvVarHeaders = "SAVE_TO_SPOTIFY_HEADERS" Scopes = "sts-content-management" @@ -286,12 +289,23 @@ func GetClientID() string { return ClientID } -const EnvVarHeaders = "SAVE_TO_SPOTIFY_HEADERS" +// additionalHeaderPrefix is the canonical MIME form of the required "X-STS-" +// header prefix. Keys are canonicalized before the prefix check, so any casing +// of X-STS-* is accepted. +const additionalHeaderPrefix = "X-Sts-" -// AdditionalHeaders holds extra HTTP headers to send on every backend API request. -// Parsed from SAVE_TO_SPOTIFY_HEADERS, a JSON array of "Key:Value" strings. -// Only headers with the X-STS- prefix are accepted; others are silently dropped. -var AdditionalHeaders = parseAdditionalHeaders() +var additionalHeaders = parseAdditionalHeaders() + +// AdditionalHeaders returns extra HTTP headers to send on backend API requests. +// Parsed from SAVE_TO_SPOTIFY_HEADERS, a JSON object of header name/value +// pairs. Keys are in canonical MIME form. Only X-STS-* headers (any casing) +// with valid HTTP header names and values are accepted; others are silently +// dropped. +func AdditionalHeaders() map[string]string { return additionalHeaders } + +// SetAdditionalHeaders replaces the additional backend headers, applying the +// same X-STS-* validation as env parsing. Used in tests. +func SetAdditionalHeaders(h map[string]string) { additionalHeaders = filterAdditionalHeaders(h) } func parseAdditionalHeaders() map[string]string { raw := os.Getenv(EnvVarHeaders) @@ -299,23 +313,31 @@ func parseAdditionalHeaders() map[string]string { return nil } - var entries []string + var entries map[string]string if err := json.Unmarshal([]byte(raw), &entries); err != nil { return nil } + return filterAdditionalHeaders(entries) +} + +// filterAdditionalHeaders canonicalizes keys and keeps only valid X-STS-* +// headers. Keys are visited in sorted order so two spellings of the same +// header collapse deterministically. +func filterAdditionalHeaders(in map[string]string) map[string]string { + keys := make([]string, 0, len(in)) + for k := range in { + keys = append(keys, k) + } + sort.Strings(keys) headers := make(map[string]string) - for _, entry := range entries { - key, val, ok := strings.Cut(entry, ":") - if !ok { + for _, k := range keys { + key := textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(k)) + val := strings.TrimSpace(in[k]) + if val == "" || !strings.HasPrefix(key, additionalHeaderPrefix) { continue } - key = strings.TrimSpace(key) - val = strings.TrimSpace(val) - if key == "" || val == "" { - continue - } - if !strings.HasPrefix(key, "X-STS-") { + if !isValidHeaderName(key) || !isValidHeaderValue(val) { continue } headers[key] = val @@ -325,3 +347,37 @@ func parseAdditionalHeaders() map[string]string { } return headers } + +// isValidHeaderName reports whether s is a valid RFC 7230 header field name +// (a token). CanonicalMIMEHeaderKey returns invalid names unchanged, so names +// it could not canonicalize are rejected here. +func isValidHeaderName(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9': + case c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\'' || + c == '*' || c == '+' || c == '-' || c == '.' || c == '^' || c == '_' || + c == '`' || c == '|' || c == '~': + default: + return false + } + } + return true +} + +// isValidHeaderValue reports whether s is a valid RFC 7230 header field value: +// no control characters other than horizontal tab. A value that fails this +// check would make net/http reject every request it is attached to. +func isValidHeaderValue(s string) bool { + for i := 0; i < len(s); i++ { + c := s[i] + if (c < 0x20 && c != '\t') || c == 0x7f { + return false + } + } + return true +} diff --git a/config/config_test.go b/config/config_test.go index ddef4dc..c2bfec5 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -185,51 +185,98 @@ func TestDPoPKeyPath(t *testing.T) { func setHeadersEnv(t *testing.T, val string) { t.Helper() t.Setenv(EnvVarHeaders, val) - orig := AdditionalHeaders - AdditionalHeaders = parseAdditionalHeaders() - t.Cleanup(func() { AdditionalHeaders = orig }) + orig := additionalHeaders + additionalHeaders = parseAdditionalHeaders() + t.Cleanup(func() { additionalHeaders = orig }) } func TestParseAdditionalHeaders(t *testing.T) { - setHeadersEnv(t, `["X-STS-Test:true","X-STS-Foo:bar"]`) + setHeadersEnv(t, `{"X-STS-Test":"true","X-STS-Foo":"bar"}`) - if got := AdditionalHeaders["X-STS-Test"]; got != "true" { - t.Errorf("X-STS-Test = %q, want %q", got, "true") + if got := AdditionalHeaders()["X-Sts-Test"]; got != "true" { + t.Errorf("X-Sts-Test = %q, want %q", got, "true") } - if got := AdditionalHeaders["X-STS-Foo"]; got != "bar" { - t.Errorf("X-STS-Foo = %q, want %q", got, "bar") + if got := AdditionalHeaders()["X-Sts-Foo"]; got != "bar" { + t.Errorf("X-Sts-Foo = %q, want %q", got, "bar") } } func TestParseAdditionalHeaders_Empty(t *testing.T) { setHeadersEnv(t, "") - if AdditionalHeaders != nil { - t.Errorf("expected nil, got %v", AdditionalHeaders) + if AdditionalHeaders() != nil { + t.Errorf("expected nil, got %v", AdditionalHeaders()) } } func TestParseAdditionalHeaders_InvalidJSON(t *testing.T) { setHeadersEnv(t, "not json") - if AdditionalHeaders != nil { - t.Errorf("expected nil for invalid JSON, got %v", AdditionalHeaders) + if AdditionalHeaders() != nil { + t.Errorf("expected nil for invalid JSON, got %v", AdditionalHeaders()) } } func TestParseAdditionalHeaders_RejectsNonSTS(t *testing.T) { - setHeadersEnv(t, `["X-STS-Test:true","X-Custom:bad","Authorization:evil"]`) + setHeadersEnv(t, `{"X-STS-Test":"true","X-Custom":"bad","Authorization":"evil"}`) - if got := AdditionalHeaders["X-STS-Test"]; got != "true" { - t.Errorf("X-STS-Test = %q, want %q", got, "true") + if got := AdditionalHeaders()["X-Sts-Test"]; got != "true" { + t.Errorf("X-Sts-Test = %q, want %q", got, "true") } - if _, ok := AdditionalHeaders["X-Custom"]; ok { - t.Error("X-Custom should have been rejected") + if len(AdditionalHeaders()) != 1 { + t.Errorf("expected 1 header, got %d: %v", len(AdditionalHeaders()), AdditionalHeaders()) } - if _, ok := AdditionalHeaders["Authorization"]; ok { - t.Error("Authorization should have been rejected") +} + +func TestParseAdditionalHeaders_CanonicalizesAnyCasing(t *testing.T) { + setHeadersEnv(t, `{"x-sts-trace-id":"123"}`) + + if got := AdditionalHeaders()["X-Sts-Trace-Id"]; got != "123" { + t.Errorf("X-Sts-Trace-Id = %q, want %q", got, "123") + } +} + +func TestParseAdditionalHeaders_CaseCollisionIsDeterministic(t *testing.T) { + setHeadersEnv(t, `{"X-STS-TEST":"upper","X-STS-Test":"mixed"}`) + + if len(AdditionalHeaders()) != 1 { + t.Fatalf("expected 1 header, got %v", AdditionalHeaders()) + } + // Keys are filtered in sorted order, so the last sorted spelling wins. + if got := AdditionalHeaders()["X-Sts-Test"]; got != "mixed" { + t.Errorf("X-Sts-Test = %q, want %q", got, "mixed") + } +} + +func TestParseAdditionalHeaders_RejectsInvalidName(t *testing.T) { + setHeadersEnv(t, `{"X-Sts-Trace Id":"abc"}`) + + if AdditionalHeaders() != nil { + t.Errorf("expected nil for invalid header name, got %v", AdditionalHeaders()) + } +} + +func TestParseAdditionalHeaders_RejectsInvalidValue(t *testing.T) { + setHeadersEnv(t, `{"X-STS-Test":"a\nb"}`) + + if AdditionalHeaders() != nil { + t.Errorf("expected nil for invalid header value, got %v", AdditionalHeaders()) + } +} + +func TestSetAdditionalHeaders_AppliesFiltering(t *testing.T) { + orig := additionalHeaders + t.Cleanup(func() { additionalHeaders = orig }) + + SetAdditionalHeaders(map[string]string{ + "x-sts-test": "1", + "Authorization": "evil", + }) + + if got := AdditionalHeaders()["X-Sts-Test"]; got != "1" { + t.Errorf("X-Sts-Test = %q, want %q", got, "1") } - if len(AdditionalHeaders) != 1 { - t.Errorf("expected 1 header, got %d: %v", len(AdditionalHeaders), AdditionalHeaders) + if len(AdditionalHeaders()) != 1 { + t.Errorf("expected 1 header, got %v", AdditionalHeaders()) } } diff --git a/internal/httpx/httpx.go b/internal/httpx/httpx.go index 8ac8c98..5f2bc46 100644 --- a/internal/httpx/httpx.go +++ b/internal/httpx/httpx.go @@ -1,6 +1,9 @@ package httpx -import "net/http" +import ( + "net/http" + "net/url" +) // UserAgentTransport applies a default User-Agent header to outbound requests. type UserAgentTransport struct { @@ -24,3 +27,46 @@ func (t UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) return rt.RoundTrip(clone) } + +// BackendHeadersTransport applies extra headers to outbound requests that +// target the backend host, leaving requests to other hosts (token endpoints, +// signed storage URLs) untouched. +type BackendHeadersTransport struct { + Base http.RoundTripper + // Headers returns the headers to apply; called per request so runtime + // changes (e.g. in tests) are honored. + Headers func() map[string]string + // BackendURL returns the backend base URL used to scope injection. + BackendURL func() string +} + +func (t BackendHeadersTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if headers := t.headersFor(req); len(headers) > 0 { + req = req.Clone(req.Context()) + for k, v := range headers { + req.Header.Set(k, v) + } + } + + rt := t.Base + if rt == nil { + rt = http.DefaultTransport + } + + return rt.RoundTrip(req) +} + +func (t BackendHeadersTransport) headersFor(req *http.Request) map[string]string { + if t.Headers == nil || t.BackendURL == nil || req.URL == nil { + return nil + } + headers := t.Headers() + if len(headers) == 0 { + return nil + } + base, err := url.Parse(t.BackendURL()) + if err != nil || base.Scheme != req.URL.Scheme || base.Host != req.URL.Host { + return nil + } + return headers +} From eb67b0a412c1ddab9aec58dbab47e3a5f44f4242 Mon Sep 17 00:00:00 2001 From: Mohammed Aboullaite Date: Tue, 7 Jul 2026 12:51:13 +0200 Subject: [PATCH 3/3] Log warning when SAVE_TO_SPOTIFY_HEADERS contains invalid JSON --- config/config.go | 1 + 1 file changed, 1 insertion(+) diff --git a/config/config.go b/config/config.go index 75e798d..ff51878 100644 --- a/config/config.go +++ b/config/config.go @@ -315,6 +315,7 @@ func parseAdditionalHeaders() map[string]string { var entries map[string]string if err := json.Unmarshal([]byte(raw), &entries); err != nil { + fmt.Fprintf(os.Stderr, "Warning: %s contains invalid JSON, ignoring: %v\n", EnvVarHeaders, err) return nil } return filterAdditionalHeaders(entries)