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..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,8 @@ {{- addImport "internal/config" true -}} {{- addImport "net" -}} {{- addImport "net/http" -}} +{{- addImport "net/url" -}} +{{- addImport "strings" -}} {{- addImport "sync" -}} {{- addImport "time" -}} {{- addImport "fmt" -}} @@ -83,7 +85,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 +99,34 @@ 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 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 new file mode 100644 index 000000000..7fa879278 --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/client/client_test.go.stmpl @@ -0,0 +1,86 @@ +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) + } +} + +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 5b420f96d..31d0820b3 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:461c08574c9a79efebb38c51b5ba8fbb407dc9c8 + internal/client/client_test.go: + 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 4f54648e9..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" @@ -15,6 +16,7 @@ import ( "openapi/internal/sdk/retry" "openapi/internal/testclient" "strconv" + "strings" "sync" "time" ) @@ -71,7 +73,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 +82,34 @@ 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 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 new file mode 100644 index 000000000..5e50e12f2 --- /dev/null +++ b/zSDKs/sdk-cli/internal/client/client_test.go @@ -0,0 +1,88 @@ +// 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) + } +} + +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) + } + } +}