diff --git a/internal/planetscale/client.go b/internal/planetscale/client.go index be9977db..74c6dbb8 100644 --- a/internal/planetscale/client.go +++ b/internal/planetscale/client.go @@ -310,6 +310,12 @@ func NewClient(opts ...ClientOption) (*Client, error) { } } + // Access and service tokens are attached by RoundTrippers on every hop. + // net/http only strips Authorization from Request.Header on cross-host + // redirects, so without this policy a 3xx Location to another host would + // re-send credentials. Match the exact-host guard used by pscale api. + c.client.CheckRedirect = makeSameHostCheckRedirect(c.baseURL.Hostname()) + c.AuditLogs = &auditlogsService{client: c} c.Backups = &backupsService{client: c} c.BranchInfrastructure = &branchInfrastructureService{client: c} @@ -622,6 +628,29 @@ func (t *serviceTokenTransport) RoundTrip(req *http.Request) (*http.Response, er return t.rt.RoundTrip(req) } +// makeSameHostCheckRedirect refuses redirects whose hostname differs from the +// configured API host. Returning http.ErrUseLastResponse stops the credential- +// bearing client before RoundTrip can attach Authorization to the new host. +// Sibling subdomains (evil.example.com vs api.example.com) are treated as +// different hosts. +func makeSameHostCheckRedirect(apiHost string) func(req *http.Request, via []*http.Request) error { + apiHost = strings.ToLower(apiHost) + return func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + + originalHost := apiHost + if originalHost == "" && len(via) > 0 { + originalHost = strings.ToLower(via[0].URL.Hostname()) + } + if originalHost != strings.ToLower(req.URL.Hostname()) { + return http.ErrUseLastResponse + } + return nil + } +} + // Error represents common errors originating from the Client. type Error struct { // msg contains the human readable string diff --git a/internal/planetscale/client_test.go b/internal/planetscale/client_test.go index 6981f4b3..5826b3a0 100644 --- a/internal/planetscale/client_test.go +++ b/internal/planetscale/client_test.go @@ -2,6 +2,8 @@ package planetscale import ( "context" + "fmt" + "net" "net/http" "net/http/httptest" "testing" @@ -222,6 +224,162 @@ func TestHandleResponse_UsesStatusCodeForInvalidNotFoundBody(t *testing.T) { } } +func TestSameHostCheckRedirect(t *testing.T) { + tests := []struct { + name string + apiHost string + redirectURL string + expectUseLastResponse bool + }{ + { + name: "exact same host", + apiHost: "api.example.com", + redirectURL: "https://api.example.com/path", + expectUseLastResponse: false, + }, + { + name: "same host different port still same hostname", + apiHost: "api.example.com", + redirectURL: "https://api.example.com:443/path", + expectUseLastResponse: false, + }, + { + name: "case-insensitive hostname match", + apiHost: "API.Example.Com", + redirectURL: "https://api.example.com/path", + expectUseLastResponse: false, + }, + { + name: "sibling subdomain is not same host", + apiHost: "api.planetscale.com", + redirectURL: "https://evil.planetscale.com/path", + expectUseLastResponse: true, + }, + { + name: "different domain", + apiHost: "api.example.com", + redirectURL: "https://attacker.tld/path", + expectUseLastResponse: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := qt.New(t) + check := makeSameHostCheckRedirect(tt.apiHost) + + req, err := http.NewRequest(http.MethodGet, tt.redirectURL, nil) + c.Assert(err, qt.IsNil) + + err = check(req, []*http.Request{}) + if tt.expectUseLastResponse { + c.Assert(err, qt.Equals, http.ErrUseLastResponse) + } else { + c.Assert(err, qt.IsNil) + } + }) + } +} + +func TestClient_DoesNotSendCredentialsOnCrossHostRedirect(t *testing.T) { + tests := []struct { + name string + option ClientOption + }{ + { + name: "access token", + option: WithAccessToken("secret-access-token"), + }, + { + name: "service token", + option: WithServiceToken("tid", "secret-service-token"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := qt.New(t) + + attackerHits := 0 + attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attackerHits++ + c.Assert(r.Header.Get("Authorization"), qt.Equals, "") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + t.Cleanup(attacker.Close) + + // httptest serves on 127.0.0.1; rewrite the Location host to + // localhost so Hostname() differs while still reaching this + // process (same port). + attackerPort := attacker.Listener.Addr().(*net.TCPAddr).Port + attackerURL := fmt.Sprintf("http://localhost:%d/harvest", attackerPort) + + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Header.Get("Authorization"), qt.Not(qt.Equals), "") + http.Redirect(w, r, attackerURL, http.StatusFound) + })) + t.Cleanup(api.Close) + + client, err := NewClient(WithBaseURL(api.URL), tt.option) + c.Assert(err, qt.IsNil) + + req, err := client.newRequest(http.MethodGet, "/v1/organizations", nil) + c.Assert(err, qt.IsNil) + + // Cross-host redirect must stop before RoundTrip attaches auth to + // the attacker host. ErrUseLastResponse returns the 302 with no error. + res, err := client.client.Do(req) + c.Assert(err, qt.IsNil) + defer res.Body.Close() + + c.Assert(res.StatusCode, qt.Equals, http.StatusFound) + c.Assert(attackerHits, qt.Equals, 0) + }) + } +} + +func TestClient_FollowsSameHostRedirectWithCredentials(t *testing.T) { + c := qt.New(t) + + var paths []string + var auths []string + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc("/v1/start", func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + auths = append(auths, r.Header.Get("Authorization")) + http.Redirect(w, r, server.URL+"/v1/final", http.StatusFound) + }) + mux.HandleFunc("/v1/final", func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + auths = append(auths, r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"ok"}`)) + }) + + client, err := NewClient(WithBaseURL(server.URL), WithServiceToken("tid", "secret")) + c.Assert(err, qt.IsNil) + + var got map[string]string + err = client.do(context.Background(), mustNewRequest(t, client, http.MethodGet, "/v1/start"), &got) + c.Assert(err, qt.IsNil) + c.Assert(got["name"], qt.Equals, "ok") + c.Assert(paths, qt.DeepEquals, []string{"/v1/start", "/v1/final"}) + c.Assert(auths, qt.DeepEquals, []string{"tid:secret", "tid:secret"}) +} + +func mustNewRequest(t *testing.T, client *Client, method, path string) *http.Request { + t.Helper() + req, err := client.newRequest(method, path, nil) + if err != nil { + t.Fatal(err) + } + return req +} + func Pointer[K any](val K) *K { return &val }