Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 9 additions & 14 deletions internal/cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,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
}
Expand Down Expand Up @@ -389,27 +391,20 @@ func parseValue(s string) (interface{}, error) {
return v, json.Unmarshal([]byte(s), &v)
}

// handleRedirect processes cross-host 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-host 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-host redirect to %s without auth headers\n", location)
fmt.Fprintf(os.Stderr, "Following cross-host 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 {
Expand Down
38 changes: 13 additions & 25 deletions internal/cmd/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,49 +143,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-host 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-host 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))
}