From 1ec4edcb3dcbdec150a1ba92d8cf6b996ea43609 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Fri, 23 Oct 2026 10:40:33 +1000 Subject: [PATCH 1/4] fix(cli): refuse cross-origin redirects so credentials stay with the configured server --- .changesets/1792715944-523a54e4.yaml | 10 ++++ templates/perms.go | 1 + .../auxiliary/internal/client/client.go.stmpl | 17 +++++- .../internal/client/client_test.go.stmpl | 56 ++++++++++++++++++ zSDKs/sdk-cli/.speakeasy/gen.lock | 4 +- zSDKs/sdk-cli/internal/client/client.go | 17 +++++- zSDKs/sdk-cli/internal/client/client_test.go | 58 +++++++++++++++++++ 7 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 .changesets/1792715944-523a54e4.yaml create mode 100644 templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl create mode 100644 zSDKs/sdk-cli/internal/client/client_test.go diff --git a/.changesets/1792715944-523a54e4.yaml b/.changesets/1792715944-523a54e4.yaml new file mode 100644 index 000000000..39cf7bb52 --- /dev/null +++ b/.changesets/1792715944-523a54e4.yaml @@ -0,0 +1,10 @@ +id: 1792715944-523a54e4 +features: + - core +targets: + - cli +type: fix +bump: patch +description: refuse cross-origin redirects so credentials are only sent to the configured server +author: TristanSpeakEasy +date: "2026-10-23" diff --git a/templates/perms.go b/templates/perms.go index 3bd7b0c7e..29a0436db 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -24,6 +24,7 @@ var fileMode = map[string]fs.FileMode{ "templates/cli/auxiliary/internal": 0o20000000755, "templates/cli/auxiliary/internal/client": 0o20000000755, "templates/cli/auxiliary/internal/client/client.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/client/client_test.go.stmpl": 0o644, "templates/cli/auxiliary/internal/client/diagnostics.go.stmpl": 0o644, "templates/cli/auxiliary/internal/clierrors": 0o20000000755, "templates/cli/auxiliary/internal/clierrors/clierrors.go.stmpl": 0o644, diff --git a/templates/templates/cli/auxiliary/internal/client/client.go.stmpl b/templates/templates/cli/auxiliary/internal/client/client.go.stmpl index e3ed8967c..f124d5db7 100644 --- a/templates/templates/cli/auxiliary/internal/client/client.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/client/client.go.stmpl @@ -6,6 +6,7 @@ {{- addImport "internal/config" true -}} {{- addImport "net" -}} {{- addImport "net/http" -}} +{{- addImport "strings" -}} {{- addImport "sync" -}} {{- addImport "time" -}} {{- addImport "fmt" -}} @@ -83,7 +84,7 @@ func NewClient(cmd *cobra.Command, allowedSecurityFields ...string) (*sdk.{{temp // Diagnostics and test client composition. // Order: test client (innermost) → diagnostics wrapper (outermost). - var httpClient HTTPClient = &http.Client{Transport: newPhaseBoundedTransport(cmd)} + var httpClient HTTPClient = &http.Client{Transport: newPhaseBoundedTransport(cmd), CheckRedirect: SameOriginRedirect} if testClient := testclient.NewTestHTTPClient(); testClient != nil { httpClient = testClient } @@ -97,6 +98,20 @@ func NewClient(cmd *cobra.Command, allowedSecurityFields ...string) (*sdk.{{temp {{- end}} } +// SameOriginRedirect refuses redirects to another scheme or host: net/http +// forwards custom credential headers (API keys) and replays 307/308 bodies +// across origins. +func SameOriginRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + from := via[0].URL + if req.URL.Scheme != from.Scheme || !strings.EqualFold(req.URL.Host, from.Host) { + return fmt.Errorf("refusing to follow a redirect from %s://%s to %s://%s: credentials are only sent to the configured server", from.Scheme, from.Host, req.URL.Scheme, req.URL.Host) + } + return nil +} + func newPhaseBoundedTransport(cmd *cobra.Command) http.RoundTripper { var phase time.Duration if s := resolveStringFlag(cmd, "timeout"); s != "" { diff --git a/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl b/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl new file mode 100644 index 000000000..9980f1f71 --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl @@ -0,0 +1,56 @@ +package client + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSameOriginRedirect(t *testing.T) { + var leakedKey string + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + leakedKey = r.Header.Get("x-api-key") + w.WriteHeader(http.StatusOK) + })) + defer other.Close() + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/cross": + http.Redirect(w, r, other.URL+"/landed", http.StatusTemporaryRedirect) + case "/same": + http.Redirect(w, r, "/landed", http.StatusFound) + default: + w.WriteHeader(http.StatusOK) + } + })) + defer origin.Close() + + httpClient := &http.Client{CheckRedirect: SameOriginRedirect} + do := func(path string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodPost, origin.URL+path, strings.NewReader("{}")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("x-api-key", "secret") + return httpClient.Do(req) + } + + res, err := do("/same") + if err != nil { + t.Fatalf("same-origin redirect: %v", err) + } + _ = res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("same-origin redirect status = %d", res.StatusCode) + } + + _, err = do("/cross") + if err == nil || !strings.Contains(err.Error(), "refusing to follow a redirect") { + t.Fatalf("cross-origin redirect error = %v", err) + } + if leakedKey != "" { + t.Fatalf("credential header reached the redirect target: %q", leakedKey) + } +} diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 5b420f96d..336671486 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -224,7 +224,9 @@ trackedFiles: internal/cli/whoami.go: last_write_checksum: sha1:ad27d6ccfed17dd213951f981030b010ababbf79 internal/client/client.go: - last_write_checksum: sha1:58db154dfc3b80e998471223136423d3ffc1ca92 + last_write_checksum: sha1:378561deb3e868f01d2a5790617ef584e74f2fd2 + internal/client/client_test.go: + last_write_checksum: sha1:10098cd4d1439ddfc4e54b2263aecb664100faed internal/client/diagnostics.go: last_write_checksum: sha1:30b37c632ff875e00e6bce57caa64050dff89fcf internal/clierrors/clierrors.go: diff --git a/zSDKs/sdk-cli/internal/client/client.go b/zSDKs/sdk-cli/internal/client/client.go index 4f54648e9..d3c1d2fff 100644 --- a/zSDKs/sdk-cli/internal/client/client.go +++ b/zSDKs/sdk-cli/internal/client/client.go @@ -15,6 +15,7 @@ import ( "openapi/internal/sdk/retry" "openapi/internal/testclient" "strconv" + "strings" "sync" "time" ) @@ -71,7 +72,7 @@ func NewClient(cmd *cobra.Command, allowedSecurityFields ...string) (*sdk.SDK, e // Diagnostics and test client composition. // Order: test client (innermost) → diagnostics wrapper (outermost). - var httpClient HTTPClient = &http.Client{Transport: newPhaseBoundedTransport(cmd)} + var httpClient HTTPClient = &http.Client{Transport: newPhaseBoundedTransport(cmd), CheckRedirect: SameOriginRedirect} if testClient := testclient.NewTestHTTPClient(); testClient != nil { httpClient = testClient } @@ -80,6 +81,20 @@ func NewClient(cmd *cobra.Command, allowedSecurityFields ...string) (*sdk.SDK, e return sdk.New(sdkOpts...), nil } +// SameOriginRedirect refuses redirects to another scheme or host: net/http +// forwards custom credential headers (API keys) and replays 307/308 bodies +// across origins. +func SameOriginRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + from := via[0].URL + if req.URL.Scheme != from.Scheme || !strings.EqualFold(req.URL.Host, from.Host) { + return fmt.Errorf("refusing to follow a redirect from %s://%s to %s://%s: credentials are only sent to the configured server", from.Scheme, from.Host, req.URL.Scheme, req.URL.Host) + } + return nil +} + func newPhaseBoundedTransport(cmd *cobra.Command) http.RoundTripper { var phase time.Duration if s := resolveStringFlag(cmd, "timeout"); s != "" { diff --git a/zSDKs/sdk-cli/internal/client/client_test.go b/zSDKs/sdk-cli/internal/client/client_test.go new file mode 100644 index 000000000..a6ecbfcee --- /dev/null +++ b/zSDKs/sdk-cli/internal/client/client_test.go @@ -0,0 +1,58 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package client + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSameOriginRedirect(t *testing.T) { + var leakedKey string + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + leakedKey = r.Header.Get("x-api-key") + w.WriteHeader(http.StatusOK) + })) + defer other.Close() + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/cross": + http.Redirect(w, r, other.URL+"/landed", http.StatusTemporaryRedirect) + case "/same": + http.Redirect(w, r, "/landed", http.StatusFound) + default: + w.WriteHeader(http.StatusOK) + } + })) + defer origin.Close() + + httpClient := &http.Client{CheckRedirect: SameOriginRedirect} + do := func(path string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodPost, origin.URL+path, strings.NewReader("{}")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("x-api-key", "secret") + return httpClient.Do(req) + } + + res, err := do("/same") + if err != nil { + t.Fatalf("same-origin redirect: %v", err) + } + _ = res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("same-origin redirect status = %d", res.StatusCode) + } + + _, err = do("/cross") + if err == nil || !strings.Contains(err.Error(), "refusing to follow a redirect") { + t.Fatalf("cross-origin redirect error = %v", err) + } + if leakedKey != "" { + t.Fatalf("credential header reached the redirect target: %q", leakedKey) + } +} From 9809f79677c0952cd214d6f48fb64d3bd5c4784e Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Fri, 23 Oct 2026 11:47:23 +1000 Subject: [PATCH 2/4] fix(cli): treat explicit default ports as the same redirect origin --- .../auxiliary/internal/client/client.go.stmpl | 17 ++++++++++- .../internal/client/client_test.go.stmpl | 30 +++++++++++++++++++ zSDKs/sdk-cli/.speakeasy/gen.lock | 4 +-- zSDKs/sdk-cli/internal/client/client.go | 17 ++++++++++- zSDKs/sdk-cli/internal/client/client_test.go | 30 +++++++++++++++++++ 5 files changed, 94 insertions(+), 4 deletions(-) diff --git a/templates/templates/cli/auxiliary/internal/client/client.go.stmpl b/templates/templates/cli/auxiliary/internal/client/client.go.stmpl index f124d5db7..a7a561cf9 100644 --- a/templates/templates/cli/auxiliary/internal/client/client.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/client/client.go.stmpl @@ -6,6 +6,7 @@ {{- addImport "internal/config" true -}} {{- addImport "net" -}} {{- addImport "net/http" -}} +{{- addImport "net/url" -}} {{- addImport "strings" -}} {{- addImport "sync" -}} {{- addImport "time" -}} @@ -106,12 +107,26 @@ func SameOriginRedirect(req *http.Request, via []*http.Request) error { return fmt.Errorf("stopped after 10 redirects") } from := via[0].URL - if req.URL.Scheme != from.Scheme || !strings.EqualFold(req.URL.Host, from.Host) { + if urlOrigin(req.URL) != urlOrigin(from) { return fmt.Errorf("refusing to follow a redirect from %s://%s to %s://%s: credentials are only sent to the configured server", from.Scheme, from.Host, req.URL.Scheme, req.URL.Host) } return nil } +func urlOrigin(u *url.URL) string { + scheme := strings.ToLower(u.Scheme) + port := u.Port() + if port == "" { + switch scheme { + case "https": + port = "443" + case "http": + port = "80" + } + } + return scheme + "://" + net.JoinHostPort(strings.ToLower(u.Hostname()), port) +} + func newPhaseBoundedTransport(cmd *cobra.Command) http.RoundTripper { var phase time.Duration if s := resolveStringFlag(cmd, "timeout"); s != "" { diff --git a/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl b/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl index 9980f1f71..7fa879278 100644 --- a/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl @@ -54,3 +54,33 @@ func TestSameOriginRedirect(t *testing.T) { t.Fatalf("credential header reached the redirect target: %q", leakedKey) } } + +func TestSameOriginRedirectOrigins(t *testing.T) { + for _, tc := range []struct { + from, to string + allowed bool + }{ + {"https://api.example.com/a", "https://api.example.com/b", true}, + {"https://api.example.com/a", "https://API.example.com:443/b", true}, + {"http://api.example.com:80/a", "http://api.example.com/b", true}, + {"https://api.example.com/a", "http://api.example.com/b", false}, + {"https://api.example.com/a", "https://api.example.com:8443/b", false}, + {"https://api.example.com/a", "https://other.example.com/b", false}, + } { + from, err := http.NewRequest(http.MethodGet, tc.from, nil) + if err != nil { + t.Fatal(err) + } + to, err := http.NewRequest(http.MethodGet, tc.to, nil) + if err != nil { + t.Fatal(err) + } + err = SameOriginRedirect(to, []*http.Request{from}) + if tc.allowed && err != nil { + t.Errorf("%s -> %s: unexpected error %v", tc.from, tc.to, err) + } + if !tc.allowed && err == nil { + t.Errorf("%s -> %s: redirect was allowed", tc.from, tc.to) + } + } +} diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 336671486..31d0820b3 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -224,9 +224,9 @@ trackedFiles: internal/cli/whoami.go: last_write_checksum: sha1:ad27d6ccfed17dd213951f981030b010ababbf79 internal/client/client.go: - last_write_checksum: sha1:378561deb3e868f01d2a5790617ef584e74f2fd2 + last_write_checksum: sha1:461c08574c9a79efebb38c51b5ba8fbb407dc9c8 internal/client/client_test.go: - last_write_checksum: sha1:10098cd4d1439ddfc4e54b2263aecb664100faed + last_write_checksum: sha1:b34318212a7159e4d235f846c9f86c7f2a93b4c3 internal/client/diagnostics.go: last_write_checksum: sha1:30b37c632ff875e00e6bce57caa64050dff89fcf internal/clierrors/clierrors.go: diff --git a/zSDKs/sdk-cli/internal/client/client.go b/zSDKs/sdk-cli/internal/client/client.go index d3c1d2fff..d1aedd042 100644 --- a/zSDKs/sdk-cli/internal/client/client.go +++ b/zSDKs/sdk-cli/internal/client/client.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "net" "net/http" + "net/url" "openapi/internal/config" "openapi/internal/flagutil" "openapi/internal/sdk" @@ -89,12 +90,26 @@ func SameOriginRedirect(req *http.Request, via []*http.Request) error { return fmt.Errorf("stopped after 10 redirects") } from := via[0].URL - if req.URL.Scheme != from.Scheme || !strings.EqualFold(req.URL.Host, from.Host) { + if urlOrigin(req.URL) != urlOrigin(from) { return fmt.Errorf("refusing to follow a redirect from %s://%s to %s://%s: credentials are only sent to the configured server", from.Scheme, from.Host, req.URL.Scheme, req.URL.Host) } return nil } +func urlOrigin(u *url.URL) string { + scheme := strings.ToLower(u.Scheme) + port := u.Port() + if port == "" { + switch scheme { + case "https": + port = "443" + case "http": + port = "80" + } + } + return scheme + "://" + net.JoinHostPort(strings.ToLower(u.Hostname()), port) +} + func newPhaseBoundedTransport(cmd *cobra.Command) http.RoundTripper { var phase time.Duration if s := resolveStringFlag(cmd, "timeout"); s != "" { diff --git a/zSDKs/sdk-cli/internal/client/client_test.go b/zSDKs/sdk-cli/internal/client/client_test.go index a6ecbfcee..5e50e12f2 100644 --- a/zSDKs/sdk-cli/internal/client/client_test.go +++ b/zSDKs/sdk-cli/internal/client/client_test.go @@ -56,3 +56,33 @@ func TestSameOriginRedirect(t *testing.T) { t.Fatalf("credential header reached the redirect target: %q", leakedKey) } } + +func TestSameOriginRedirectOrigins(t *testing.T) { + for _, tc := range []struct { + from, to string + allowed bool + }{ + {"https://api.example.com/a", "https://api.example.com/b", true}, + {"https://api.example.com/a", "https://API.example.com:443/b", true}, + {"http://api.example.com:80/a", "http://api.example.com/b", true}, + {"https://api.example.com/a", "http://api.example.com/b", false}, + {"https://api.example.com/a", "https://api.example.com:8443/b", false}, + {"https://api.example.com/a", "https://other.example.com/b", false}, + } { + from, err := http.NewRequest(http.MethodGet, tc.from, nil) + if err != nil { + t.Fatal(err) + } + to, err := http.NewRequest(http.MethodGet, tc.to, nil) + if err != nil { + t.Fatal(err) + } + err = SameOriginRedirect(to, []*http.Request{from}) + if tc.allowed && err != nil { + t.Errorf("%s -> %s: unexpected error %v", tc.from, tc.to, err) + } + if !tc.allowed && err == nil { + t.Errorf("%s -> %s: redirect was allowed", tc.from, tc.to) + } + } +} From 14793e0e3387fae779ab7887069dfe59031305da Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Mon, 26 Oct 2026 09:21:36 +1000 Subject: [PATCH 3/4] fix(cli): refuse redirects that change the request method and classify refused redirects as protocol errors --- .../auxiliary/internal/client/client.go.stmpl | 27 +++++++++++-- .../internal/client/client_test.go.stmpl | 40 ++++++++++++++++++- .../internal/output/classify.go.stmpl | 4 ++ zSDKs/sdk-cli/.speakeasy/gen.lock | 6 +-- zSDKs/sdk-cli/internal/client/client.go | 27 +++++++++++-- zSDKs/sdk-cli/internal/client/client_test.go | 40 ++++++++++++++++++- zSDKs/sdk-cli/internal/output/classify.go | 4 ++ 7 files changed, 133 insertions(+), 15 deletions(-) diff --git a/templates/templates/cli/auxiliary/internal/client/client.go.stmpl b/templates/templates/cli/auxiliary/internal/client/client.go.stmpl index a7a561cf9..1da1e5029 100644 --- a/templates/templates/cli/auxiliary/internal/client/client.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/client/client.go.stmpl @@ -99,20 +99,39 @@ func NewClient(cmd *cobra.Command, allowedSecurityFields ...string) (*sdk.{{temp {{- end}} } -// SameOriginRedirect refuses redirects to another scheme or host: net/http -// forwards custom credential headers (API keys) and replays 307/308 bodies -// across origins. +// SameOriginRedirect refuses redirects to another scheme or host, and +// redirects that change the request method: net/http forwards custom +// credential headers (API keys) across origins and turns a POST answered with +// 301/302/303 into a bodiless GET. func SameOriginRedirect(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("stopped after 10 redirects") } from := via[0].URL if urlOrigin(req.URL) != urlOrigin(from) { - return fmt.Errorf("refusing to follow a redirect from %s://%s to %s://%s: credentials are only sent to the configured server", from.Scheme, from.Host, req.URL.Scheme, req.URL.Host) + return &RedirectRefusedError{message: fmt.Sprintf("refusing to follow a redirect from %s://%s to %s://%s: credentials are only sent to the configured server", from.Scheme, from.Host, req.URL.Scheme, req.URL.Host)} + } + if prev := via[len(via)-1]; req.Method != prev.Method { + return &RedirectRefusedError{message: fmt.Sprintf("refusing to follow a redirect to %s that would send %s instead of %s: the server tried to convert the request", req.URL.Path, req.Method, prev.Method)} } return nil } +// RedirectRefusedError is returned for a redirect the CLI declined to follow. +type RedirectRefusedError struct { + message string +} + +func (e *RedirectRefusedError) Error() string { return e.message } + +func (e *RedirectRefusedError) CLIRedirectRefused() bool { return true } + +func (e *RedirectRefusedError) CLIHints() []string { + return []string{"Point --server-url at the endpoint the server redirects to, so the request needs no redirect"} +} + +func (e *RedirectRefusedError) CLIOmitTypeHints() bool { return true } + func urlOrigin(u *url.URL) string { scheme := strings.ToLower(u.Scheme) port := u.Port() diff --git a/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl b/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl index 7fa879278..2bf2db01c 100644 --- a/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl @@ -1,14 +1,20 @@ package client import ( + "errors" + "io" "net/http" "net/http/httptest" "strings" "testing" + + "github.com/spf13/cobra" + + "{{getGolangPackage}}/internal/output" ) func TestSameOriginRedirect(t *testing.T) { - var leakedKey string + var leakedKey, landedMethod, landedBody string other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { leakedKey = r.Header.Get("x-api-key") w.WriteHeader(http.StatusOK) @@ -20,8 +26,12 @@ func TestSameOriginRedirect(t *testing.T) { case "/cross": http.Redirect(w, r, other.URL+"/landed", http.StatusTemporaryRedirect) case "/same": - http.Redirect(w, r, "/landed", http.StatusFound) + http.Redirect(w, r, "/landed", http.StatusTemporaryRedirect) + case "/convert": + http.Redirect(w, r, "/landed", http.StatusSeeOther) default: + body, _ := io.ReadAll(r.Body) + landedMethod, landedBody = r.Method, string(body) w.WriteHeader(http.StatusOK) } })) @@ -45,6 +55,18 @@ func TestSameOriginRedirect(t *testing.T) { if res.StatusCode != http.StatusOK { t.Fatalf("same-origin redirect status = %d", res.StatusCode) } + if landedMethod != http.MethodPost || landedBody != "{}" { + t.Fatalf("307 redirect landed as %s with body %q", landedMethod, landedBody) + } + + landedMethod = "" + _, err = do("/convert") + if err == nil || !strings.Contains(err.Error(), "the server tried to convert the request") { + t.Fatalf("method-changing redirect error = %v", err) + } + if landedMethod != "" { + t.Fatalf("method-changing redirect reached the target as %s", landedMethod) + } _, err = do("/cross") if err == nil || !strings.Contains(err.Error(), "refusing to follow a redirect") { @@ -53,6 +75,20 @@ func TestSameOriginRedirect(t *testing.T) { if leakedKey != "" { t.Fatalf("credential header reached the redirect target: %q", leakedKey) } + var refused *RedirectRefusedError + if !errors.As(err, &refused) { + t.Fatalf("cross-origin redirect error is %T, want *RedirectRefusedError", err) + } + + c := output.Classify(&cobra.Command{}, err) + if c.Type != output.ErrorTypeProtocol { + t.Fatalf("refused redirect classified as %s", c.Type) + } + for _, hint := range c.Hints { + if strings.Contains(hint, "network connectivity") { + t.Fatalf("refused redirect carries connection hints: %v", c.Hints) + } + } } func TestSameOriginRedirectOrigins(t *testing.T) { diff --git a/templates/templates/cli/auxiliary/internal/output/classify.go.stmpl b/templates/templates/cli/auxiliary/internal/output/classify.go.stmpl index 32ad0bfc6..d6ed673a6 100644 --- a/templates/templates/cli/auxiliary/internal/output/classify.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/classify.go.stmpl @@ -466,6 +466,10 @@ func classifyStatuslessError(err error, origin ErrorOrigin) ErrorType { if errors.As(err, &streamErr) { return ErrorTypeAPI } + var refused interface{ CLIRedirectRefused() bool } + if errors.As(err, &refused) && refused.CLIRedirectRefused() { + return ErrorTypeProtocol + } if origin == ErrorOriginCLI { if isConnectionError(err) { return ErrorTypeConnection diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 31d0820b3..3211629f0 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -224,9 +224,9 @@ trackedFiles: internal/cli/whoami.go: last_write_checksum: sha1:ad27d6ccfed17dd213951f981030b010ababbf79 internal/client/client.go: - last_write_checksum: sha1:461c08574c9a79efebb38c51b5ba8fbb407dc9c8 + last_write_checksum: sha1:9d81fb507f12e5d09b0e993fa9f38451591fc7b6 internal/client/client_test.go: - last_write_checksum: sha1:b34318212a7159e4d235f846c9f86c7f2a93b4c3 + last_write_checksum: sha1:a5d768bff17445b98030feb537f130c54ca1071a internal/client/diagnostics.go: last_write_checksum: sha1:30b37c632ff875e00e6bce57caa64050dff89fcf internal/clierrors/clierrors.go: @@ -274,7 +274,7 @@ trackedFiles: internal/output/binary.go: last_write_checksum: sha1:843f1fafc38ee03782e978f617a07bb543f06d3b internal/output/classify.go: - last_write_checksum: sha1:11ad9caaff14fc584399c2f88b328d9cc74f92a9 + last_write_checksum: sha1:2d17c649096fbd0384e2a8be2e1e5efe189ed40d internal/output/color.go: last_write_checksum: sha1:c6f41f9691f6209f480d153c15c3424d8ca5883d internal/output/errortable.go: diff --git a/zSDKs/sdk-cli/internal/client/client.go b/zSDKs/sdk-cli/internal/client/client.go index d1aedd042..3db9e568f 100644 --- a/zSDKs/sdk-cli/internal/client/client.go +++ b/zSDKs/sdk-cli/internal/client/client.go @@ -82,20 +82,39 @@ func NewClient(cmd *cobra.Command, allowedSecurityFields ...string) (*sdk.SDK, e return sdk.New(sdkOpts...), nil } -// SameOriginRedirect refuses redirects to another scheme or host: net/http -// forwards custom credential headers (API keys) and replays 307/308 bodies -// across origins. +// SameOriginRedirect refuses redirects to another scheme or host, and +// redirects that change the request method: net/http forwards custom +// credential headers (API keys) across origins and turns a POST answered with +// 301/302/303 into a bodiless GET. func SameOriginRedirect(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("stopped after 10 redirects") } from := via[0].URL if urlOrigin(req.URL) != urlOrigin(from) { - return fmt.Errorf("refusing to follow a redirect from %s://%s to %s://%s: credentials are only sent to the configured server", from.Scheme, from.Host, req.URL.Scheme, req.URL.Host) + return &RedirectRefusedError{message: fmt.Sprintf("refusing to follow a redirect from %s://%s to %s://%s: credentials are only sent to the configured server", from.Scheme, from.Host, req.URL.Scheme, req.URL.Host)} + } + if prev := via[len(via)-1]; req.Method != prev.Method { + return &RedirectRefusedError{message: fmt.Sprintf("refusing to follow a redirect to %s that would send %s instead of %s: the server tried to convert the request", req.URL.Path, req.Method, prev.Method)} } return nil } +// RedirectRefusedError is returned for a redirect the CLI declined to follow. +type RedirectRefusedError struct { + message string +} + +func (e *RedirectRefusedError) Error() string { return e.message } + +func (e *RedirectRefusedError) CLIRedirectRefused() bool { return true } + +func (e *RedirectRefusedError) CLIHints() []string { + return []string{"Point --server-url at the endpoint the server redirects to, so the request needs no redirect"} +} + +func (e *RedirectRefusedError) CLIOmitTypeHints() bool { return true } + func urlOrigin(u *url.URL) string { scheme := strings.ToLower(u.Scheme) port := u.Port() diff --git a/zSDKs/sdk-cli/internal/client/client_test.go b/zSDKs/sdk-cli/internal/client/client_test.go index 5e50e12f2..4f5613c02 100644 --- a/zSDKs/sdk-cli/internal/client/client_test.go +++ b/zSDKs/sdk-cli/internal/client/client_test.go @@ -3,14 +3,20 @@ package client import ( + "errors" + "io" "net/http" "net/http/httptest" "strings" "testing" + + "github.com/spf13/cobra" + + "openapi/internal/output" ) func TestSameOriginRedirect(t *testing.T) { - var leakedKey string + var leakedKey, landedMethod, landedBody string other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { leakedKey = r.Header.Get("x-api-key") w.WriteHeader(http.StatusOK) @@ -22,8 +28,12 @@ func TestSameOriginRedirect(t *testing.T) { case "/cross": http.Redirect(w, r, other.URL+"/landed", http.StatusTemporaryRedirect) case "/same": - http.Redirect(w, r, "/landed", http.StatusFound) + http.Redirect(w, r, "/landed", http.StatusTemporaryRedirect) + case "/convert": + http.Redirect(w, r, "/landed", http.StatusSeeOther) default: + body, _ := io.ReadAll(r.Body) + landedMethod, landedBody = r.Method, string(body) w.WriteHeader(http.StatusOK) } })) @@ -47,6 +57,18 @@ func TestSameOriginRedirect(t *testing.T) { if res.StatusCode != http.StatusOK { t.Fatalf("same-origin redirect status = %d", res.StatusCode) } + if landedMethod != http.MethodPost || landedBody != "{}" { + t.Fatalf("307 redirect landed as %s with body %q", landedMethod, landedBody) + } + + landedMethod = "" + _, err = do("/convert") + if err == nil || !strings.Contains(err.Error(), "the server tried to convert the request") { + t.Fatalf("method-changing redirect error = %v", err) + } + if landedMethod != "" { + t.Fatalf("method-changing redirect reached the target as %s", landedMethod) + } _, err = do("/cross") if err == nil || !strings.Contains(err.Error(), "refusing to follow a redirect") { @@ -55,6 +77,20 @@ func TestSameOriginRedirect(t *testing.T) { if leakedKey != "" { t.Fatalf("credential header reached the redirect target: %q", leakedKey) } + var refused *RedirectRefusedError + if !errors.As(err, &refused) { + t.Fatalf("cross-origin redirect error is %T, want *RedirectRefusedError", err) + } + + c := output.Classify(&cobra.Command{}, err) + if c.Type != output.ErrorTypeProtocol { + t.Fatalf("refused redirect classified as %s", c.Type) + } + for _, hint := range c.Hints { + if strings.Contains(hint, "network connectivity") { + t.Fatalf("refused redirect carries connection hints: %v", c.Hints) + } + } } func TestSameOriginRedirectOrigins(t *testing.T) { diff --git a/zSDKs/sdk-cli/internal/output/classify.go b/zSDKs/sdk-cli/internal/output/classify.go index f056c38ed..90d24516b 100644 --- a/zSDKs/sdk-cli/internal/output/classify.go +++ b/zSDKs/sdk-cli/internal/output/classify.go @@ -452,6 +452,10 @@ func classifyStatuslessError(err error, origin ErrorOrigin) ErrorType { if errors.As(err, &streamErr) { return ErrorTypeAPI } + var refused interface{ CLIRedirectRefused() bool } + if errors.As(err, &refused) && refused.CLIRedirectRefused() { + return ErrorTypeProtocol + } if origin == ErrorOriginCLI { if isConnectionError(err) { return ErrorTypeConnection From ebe11639945d82d2210227178df89e07ab46a55f Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 27 Oct 2026 08:58:31 +1000 Subject: [PATCH 4/4] fix(cli): compare redirect hosts with ASCII-only case folding and redact refused redirect URLs --- .../auxiliary/internal/client/client.go.stmpl | 38 ++++++++- .../internal/client/client_test.go.stmpl | 81 +++++++++++++++++++ zSDKs/sdk-cli/.speakeasy/gen.lock | 4 +- zSDKs/sdk-cli/internal/client/client.go | 38 ++++++++- zSDKs/sdk-cli/internal/client/client_test.go | 81 +++++++++++++++++++ 5 files changed, 236 insertions(+), 6 deletions(-) diff --git a/templates/templates/cli/auxiliary/internal/client/client.go.stmpl b/templates/templates/cli/auxiliary/internal/client/client.go.stmpl index 1da1e5029..5f4ac8335 100644 --- a/templates/templates/cli/auxiliary/internal/client/client.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/client/client.go.stmpl @@ -7,6 +7,7 @@ {{- addImport "net" -}} {{- addImport "net/http" -}} {{- addImport "net/url" -}} +{{- addImport "errors" -}} {{- addImport "strings" -}} {{- addImport "sync" -}} {{- addImport "time" -}} @@ -85,7 +86,7 @@ func NewClient(cmd *cobra.Command, allowedSecurityFields ...string) (*sdk.{{temp // Diagnostics and test client composition. // Order: test client (innermost) → diagnostics wrapper (outermost). - var httpClient HTTPClient = &http.Client{Transport: newPhaseBoundedTransport(cmd), CheckRedirect: SameOriginRedirect} + var httpClient HTTPClient = &redirectGuardClient{&http.Client{Transport: newPhaseBoundedTransport(cmd), CheckRedirect: SameOriginRedirect}} if testClient := testclient.NewTestHTTPClient(); testClient != nil { httpClient = testClient } @@ -132,6 +133,27 @@ func (e *RedirectRefusedError) CLIHints() []string { func (e *RedirectRefusedError) CLIOmitTypeHints() bool { return true } +// redirectGuardClient strips the query, fragment and userinfo from the +// Location net/http attaches to a refused redirect, so tokens in it are never +// printed in error or debug output. +type redirectGuardClient struct { + *http.Client +} + +func (c *redirectGuardClient) Do(req *http.Request) (*http.Response, error) { + res, err := c.Client.Do(req) + var urlErr *url.Error + var refused *RedirectRefusedError + if errors.As(err, &urlErr) && errors.As(err, &refused) { + if u, parseErr := url.Parse(urlErr.URL); parseErr == nil { + urlErr.URL = (&url.URL{Scheme: u.Scheme, Host: u.Host, Path: u.Path}).String() + } else { + urlErr.URL = "" + } + } + return res, err +} + func urlOrigin(u *url.URL) string { scheme := strings.ToLower(u.Scheme) port := u.Port() @@ -143,7 +165,19 @@ func urlOrigin(u *url.URL) string { port = "80" } } - return scheme + "://" + net.JoinHostPort(strings.ToLower(u.Hostname()), port) + return scheme + "://" + net.JoinHostPort(asciiLower(u.Hostname()), port) +} + +// asciiLower folds only A-Z. Unicode case folding would equate hosts the +// transport dials as different IDNA names (U+0130 "İ" and "i"). +func asciiLower(s string) string { + b := []byte(s) + for i, c := range b { + if 'A' <= c && c <= 'Z' { + b[i] = c + ('a' - 'A') + } + } + return string(b) } func newPhaseBoundedTransport(cmd *cobra.Command) http.RoundTripper { diff --git a/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl b/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl index 2bf2db01c..ecdc1fda1 100644 --- a/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl @@ -1,8 +1,12 @@ package client import ( + "bytes" + "context" "errors" + "fmt" "io" + "net" "net/http" "net/http/httptest" "strings" @@ -102,6 +106,8 @@ func TestSameOriginRedirectOrigins(t *testing.T) { {"https://api.example.com/a", "http://api.example.com/b", false}, {"https://api.example.com/a", "https://api.example.com:8443/b", false}, {"https://api.example.com/a", "https://other.example.com/b", false}, + {"http://i.example/a", "http://\u0130.example/b", false}, + {"http://\u0130.example/a", "http://\u0130.example/b", true}, } { from, err := http.NewRequest(http.MethodGet, tc.from, nil) if err != nil { @@ -120,3 +126,78 @@ func TestSameOriginRedirectOrigins(t *testing.T) { } } } + +func TestSameOriginRedirectNeverDialsIDNAVariant(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/start" { + w.Header()["Location"] = []string{"http://\u0130.example/landed"} + w.WriteHeader(http.StatusTemporaryRedirect) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + var dialed []string + dialer := &net.Dialer{} + transport := &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + dialed = append(dialed, addr) + return dialer.DialContext(ctx, network, server.Listener.Addr().String()) + }} + defer transport.CloseIdleConnections() + httpClient := &redirectGuardClient{&http.Client{Transport: transport, CheckRedirect: SameOriginRedirect}} + + req, err := http.NewRequest(http.MethodPost, "http://i.example/start", strings.NewReader("{}")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("x-api-key", "secret") + _, err = httpClient.Do(req) + var refused *RedirectRefusedError + if !errors.As(err, &refused) { + t.Fatalf("redirect to an IDNA variant host was not refused: %v", err) + } + if len(dialed) != 1 || dialed[0] != "i.example:80" { + t.Fatalf("dialed %v, want only i.example:80", dialed) + } +} + +func TestRefusedRedirectRedactsLocation(t *testing.T) { + const secret = "synthetic-secret" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/convert" { + http.Redirect(w, r, "https://user:"+secret+"@"+r.Host+"/landed?access_token="+secret+"#"+secret, http.StatusSeeOther) + return + } + http.Redirect(w, r, "/landed?access_token="+secret+"#"+secret, http.StatusSeeOther) + })) + defer server.Close() + + for _, path := range []string{"/same-origin", "/convert"} { + var debug bytes.Buffer + httpClient := &DebugClient{ + Inner: &redirectGuardClient{&http.Client{CheckRedirect: SameOriginRedirect}}, + Stderr: &debug, + } + req, err := http.NewRequest(http.MethodPost, server.URL+path, strings.NewReader("{}")) + if err != nil { + t.Fatal(err) + } + _, err = httpClient.Do(req) + var refused *RedirectRefusedError + if !errors.As(err, &refused) { + t.Fatalf("%s: redirect was not refused: %v", path, err) + } + wrapped := fmt.Errorf("error sending request: %w", err) + c := output.Classify(&cobra.Command{}, wrapped) + for name, text := range map[string]string{ + "error": wrapped.Error(), + "classification": fmt.Sprintf("%+v", c), + "debug": debug.String(), + } { + if strings.Contains(text, secret) { + t.Errorf("%s: %s output contains the redirect secret: %s", path, name, text) + } + } + } +} diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 0d3cce911..47f4a9e71 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -224,9 +224,9 @@ trackedFiles: internal/cli/whoami.go: last_write_checksum: sha1:dbbfb30c1445584c4365afa9a81354a6721d8a50 internal/client/client.go: - last_write_checksum: sha1:9d81fb507f12e5d09b0e993fa9f38451591fc7b6 + last_write_checksum: sha1:a9c5d6b2d6251fa62325f3668db8b1920596e0d3 internal/client/client_test.go: - last_write_checksum: sha1:a5d768bff17445b98030feb537f130c54ca1071a + last_write_checksum: sha1:6e34b14aef184c0c61202f431b88717398e3f4ca internal/client/diagnostics.go: last_write_checksum: sha1:30b37c632ff875e00e6bce57caa64050dff89fcf internal/clierrors/clierrors.go: diff --git a/zSDKs/sdk-cli/internal/client/client.go b/zSDKs/sdk-cli/internal/client/client.go index 3db9e568f..097d904bf 100644 --- a/zSDKs/sdk-cli/internal/client/client.go +++ b/zSDKs/sdk-cli/internal/client/client.go @@ -4,6 +4,7 @@ package client import ( "encoding/json" + "errors" "fmt" "github.com/spf13/cobra" "net" @@ -73,7 +74,7 @@ func NewClient(cmd *cobra.Command, allowedSecurityFields ...string) (*sdk.SDK, e // Diagnostics and test client composition. // Order: test client (innermost) → diagnostics wrapper (outermost). - var httpClient HTTPClient = &http.Client{Transport: newPhaseBoundedTransport(cmd), CheckRedirect: SameOriginRedirect} + var httpClient HTTPClient = &redirectGuardClient{&http.Client{Transport: newPhaseBoundedTransport(cmd), CheckRedirect: SameOriginRedirect}} if testClient := testclient.NewTestHTTPClient(); testClient != nil { httpClient = testClient } @@ -115,6 +116,27 @@ func (e *RedirectRefusedError) CLIHints() []string { func (e *RedirectRefusedError) CLIOmitTypeHints() bool { return true } +// redirectGuardClient strips the query, fragment and userinfo from the +// Location net/http attaches to a refused redirect, so tokens in it are never +// printed in error or debug output. +type redirectGuardClient struct { + *http.Client +} + +func (c *redirectGuardClient) Do(req *http.Request) (*http.Response, error) { + res, err := c.Client.Do(req) + var urlErr *url.Error + var refused *RedirectRefusedError + if errors.As(err, &urlErr) && errors.As(err, &refused) { + if u, parseErr := url.Parse(urlErr.URL); parseErr == nil { + urlErr.URL = (&url.URL{Scheme: u.Scheme, Host: u.Host, Path: u.Path}).String() + } else { + urlErr.URL = "" + } + } + return res, err +} + func urlOrigin(u *url.URL) string { scheme := strings.ToLower(u.Scheme) port := u.Port() @@ -126,7 +148,19 @@ func urlOrigin(u *url.URL) string { port = "80" } } - return scheme + "://" + net.JoinHostPort(strings.ToLower(u.Hostname()), port) + return scheme + "://" + net.JoinHostPort(asciiLower(u.Hostname()), port) +} + +// asciiLower folds only A-Z. Unicode case folding would equate hosts the +// transport dials as different IDNA names (U+0130 "İ" and "i"). +func asciiLower(s string) string { + b := []byte(s) + for i, c := range b { + if 'A' <= c && c <= 'Z' { + b[i] = c + ('a' - 'A') + } + } + return string(b) } func newPhaseBoundedTransport(cmd *cobra.Command) http.RoundTripper { diff --git a/zSDKs/sdk-cli/internal/client/client_test.go b/zSDKs/sdk-cli/internal/client/client_test.go index 4f5613c02..91df07c7f 100644 --- a/zSDKs/sdk-cli/internal/client/client_test.go +++ b/zSDKs/sdk-cli/internal/client/client_test.go @@ -3,8 +3,12 @@ package client import ( + "bytes" + "context" "errors" + "fmt" "io" + "net" "net/http" "net/http/httptest" "strings" @@ -104,6 +108,8 @@ func TestSameOriginRedirectOrigins(t *testing.T) { {"https://api.example.com/a", "http://api.example.com/b", false}, {"https://api.example.com/a", "https://api.example.com:8443/b", false}, {"https://api.example.com/a", "https://other.example.com/b", false}, + {"http://i.example/a", "http://\u0130.example/b", false}, + {"http://\u0130.example/a", "http://\u0130.example/b", true}, } { from, err := http.NewRequest(http.MethodGet, tc.from, nil) if err != nil { @@ -122,3 +128,78 @@ func TestSameOriginRedirectOrigins(t *testing.T) { } } } + +func TestSameOriginRedirectNeverDialsIDNAVariant(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/start" { + w.Header()["Location"] = []string{"http://\u0130.example/landed"} + w.WriteHeader(http.StatusTemporaryRedirect) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + var dialed []string + dialer := &net.Dialer{} + transport := &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + dialed = append(dialed, addr) + return dialer.DialContext(ctx, network, server.Listener.Addr().String()) + }} + defer transport.CloseIdleConnections() + httpClient := &redirectGuardClient{&http.Client{Transport: transport, CheckRedirect: SameOriginRedirect}} + + req, err := http.NewRequest(http.MethodPost, "http://i.example/start", strings.NewReader("{}")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("x-api-key", "secret") + _, err = httpClient.Do(req) + var refused *RedirectRefusedError + if !errors.As(err, &refused) { + t.Fatalf("redirect to an IDNA variant host was not refused: %v", err) + } + if len(dialed) != 1 || dialed[0] != "i.example:80" { + t.Fatalf("dialed %v, want only i.example:80", dialed) + } +} + +func TestRefusedRedirectRedactsLocation(t *testing.T) { + const secret = "synthetic-secret" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/convert" { + http.Redirect(w, r, "https://user:"+secret+"@"+r.Host+"/landed?access_token="+secret+"#"+secret, http.StatusSeeOther) + return + } + http.Redirect(w, r, "/landed?access_token="+secret+"#"+secret, http.StatusSeeOther) + })) + defer server.Close() + + for _, path := range []string{"/same-origin", "/convert"} { + var debug bytes.Buffer + httpClient := &DebugClient{ + Inner: &redirectGuardClient{&http.Client{CheckRedirect: SameOriginRedirect}}, + Stderr: &debug, + } + req, err := http.NewRequest(http.MethodPost, server.URL+path, strings.NewReader("{}")) + if err != nil { + t.Fatal(err) + } + _, err = httpClient.Do(req) + var refused *RedirectRefusedError + if !errors.As(err, &refused) { + t.Fatalf("%s: redirect was not refused: %v", path, err) + } + wrapped := fmt.Errorf("error sending request: %w", err) + c := output.Classify(&cobra.Command{}, wrapped) + for name, text := range map[string]string{ + "error": wrapped.Error(), + "classification": fmt.Sprintf("%+v", c), + "debug": debug.String(), + } { + if strings.Contains(text, secret) { + t.Errorf("%s: %s output contains the redirect secret: %s", path, name, text) + } + } + } +}