From d0168ff69ec5ea72eddd4990f74ce912a6a2033f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 19:46:15 +0000 Subject: [PATCH] Fix credential header leak on cross-domain API redirects handleRedirect previously copied all original request headers except Authorization when following a cross-domain Location. Users can put secrets in arbitrary --header/-H values (Cookie, X-Api-Key, Proxy-Authorization), which were then forwarded to the redirect target. Follow cross-domain redirects with a fresh request that carries none of the original headers. Co-authored-by: Mike Coutermarsh --- internal/cmd/api/api.go | 23 +++++++++------------- internal/cmd/api/api_test.go | 38 ++++++++++++------------------------ 2 files changed, 22 insertions(+), 39 deletions(-) diff --git a/internal/cmd/api/api.go b/internal/cmd/api/api.go index 33b0a5f47..19d999707 100644 --- a/internal/cmd/api/api.go +++ b/internal/cmd/api/api.go @@ -185,7 +185,9 @@ func ApiCmd(ch *cmdutil.Helper, userAgent string, defaultHeaders map[string]stri if res.StatusCode >= 300 && res.StatusCode < 400 { location := res.Header.Get("Location") if location != "" { - newRes, handleErr := handleRedirect(ctx, req, res, location, ch.Debug()) + // Do not pass original request headers: secrets in -H values + // must not be sent to an attacker-controlled Location host. + newRes, handleErr := handleRedirect(ctx, location, ch.Debug()) if handleErr != nil { return handleErr } @@ -387,27 +389,20 @@ func parseValue(s string) (interface{}, error) { return v, json.Unmarshal([]byte(s), &v) } -// handleRedirect processes cross-domain redirects by following the redirect -// manually without sending authentication headers -func handleRedirect(ctx context.Context, originalReq *http.Request, originalRes *http.Response, location string, debug bool) (*http.Response, error) { - // Create a new request without auth headers +// handleRedirect follows a cross-domain redirect without forwarding credentials +// or user-supplied headers. Users may put secrets in arbitrary --header/-H +// values (Cookie, X-Api-Key, etc.), so the redirect request is created fresh +// and must not copy headers from the original request. +func handleRedirect(ctx context.Context, location string, debug bool) (*http.Response, error) { redirectReq, err := http.NewRequestWithContext(ctx, "GET", location, nil) if err != nil { return nil, fmt.Errorf("preparing redirect request: %w", err) } - // Copy all headers except Authorization - for k, v := range originalReq.Header { - if !strings.EqualFold(k, "Authorization") { - redirectReq.Header[k] = v - } - } - if debug { - fmt.Fprintf(os.Stderr, "Following cross-domain redirect to %s without auth headers\n", location) + fmt.Fprintf(os.Stderr, "Following cross-domain redirect to %s without original request headers\n", location) } - // Use a new client without auth for the redirect redirectClient := &http.Client{} redirectRes, err := redirectClient.Do(redirectReq) if err != nil { diff --git a/internal/cmd/api/api_test.go b/internal/cmd/api/api_test.go index 3ab942577..8e5a8b6ed 100644 --- a/internal/cmd/api/api_test.go +++ b/internal/cmd/api/api_test.go @@ -173,49 +173,37 @@ func TestRedirectCheck(t *testing.T) { } func TestHandleRedirect(t *testing.T) { - // Create a test context ctx := context.Background() - // Create an original request with auth header - originalReq, _ := http.NewRequest("GET", "https://api.example.com/path", nil) - originalReq.Header.Set("Authorization", "Bearer token123") - originalReq.Header.Set("User-Agent", "test-agent") - - // Create a mock original response - originalRes := &http.Response{ - StatusCode: 302, - Header: http.Header{}, + // Cross-domain redirects must not carry credentials or user-supplied headers + // from the original request (Authorization, Cookie, X-Api-Key, etc.). + credentialHeaders := []string{ + "Authorization", + "Proxy-Authorization", + "Cookie", + "X-Api-Key", } - originalRes.Header.Set("Location", "https://other-domain.com/newpath") - // Mock a response from the redirect target mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Ensure auth header is not present - if r.Header.Get("Authorization") != "" { - t.Error("Auth header was incorrectly passed to redirect target") - } - - // Ensure other headers were preserved - if r.Header.Get("User-Agent") != "test-agent" { - t.Error("Other headers were not preserved in redirect") + for _, name := range credentialHeaders { + require.Empty(t, r.Header.Get(name), + "credential header %q must not be present on cross-domain redirect request", name) } + // Accept is a common original-request header; it must not be forwarded. + require.Empty(t, r.Header.Get("Accept")) w.Write([]byte("Redirect target content")) })) defer mockServer.Close() - // Test the handleRedirect function with the mock server - redirectRes, err := handleRedirect(ctx, originalReq, originalRes, mockServer.URL, false) + redirectRes, err := handleRedirect(ctx, mockServer.URL, false) - // Verify the result require.NoError(t, err) require.NotNil(t, redirectRes) - // Read the response body body, err := io.ReadAll(redirectRes.Body) require.NoError(t, err) redirectRes.Body.Close() - // Verify the response content require.Equal(t, "Redirect target content", string(body)) }