cli/api: keep the bearer on its origin across redirects and cross-host paths · Entire

cli/api: keep the bearer on its origin across redirects and cross-host paths

aaeb854→main· Soph·2w ago·3 files·+110 added/-1 removed

The api.Client used the default redirect-following http.Client and re-added Authorization on every hop, so a backend redirect to another host — or a path that resolved cross-host — would carry the Entire token off-origin (the earlier path check only guarded entire api's own input).

Together the token is only ever sent to the API's own host. Also make the logged-out core path in entire api return a normal (non-silent) error so the "run 'entire login'" hint is printed instead of a bare non-zero exit.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com

Sessions

d337489b2f01View transcript

Changes

3

7 unmodified lines

420 unmodified lines

// rejectCrossHostRedirect stops a redirect chain from leaving the origin the // client was built for. Same-host redirects (e.g. a trailing-slash normalize) // still follow, up to Go's usual 10-hop cap. func rejectCrossHostRedirect(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return errors.New("stopped after 10 redirects") } if len(via) > 0 && !strings.EqualFold(req.URL.Host, via[0].URL.Host) { return fmt.Errorf("refusing redirect to a different host (%s → %s): the Entire bearer must not leave its origin", via[0].URL.Host, req.URL.Host) } return nil }

// requireSameHost rejects an endpoint whose host differs from the base URL's. // It guards the direct case (a path that resolved to another host); redirects // are handled by rejectCrossHostRedirect. func requireSameHost(baseURL, endpoint string) error { b, err := url.Parse(baseURL) if err != nil { return fmt.Errorf("parse base URL: %w", err) } e, err := url.Parse(endpoint) if err != nil { return fmt.Errorf("parse endpoint URL: %w", err) } if !strings.EqualFold(b.Host, e.Host) { return fmt.Errorf("refusing to send an authenticated request to %q, which is not the API host %q", e.Host, b.Host) } return nil }

// TestClient_RefusesCrossHostPath verifies a path that resolves to a host other // than the client's base is rejected before any request (and its bearer) is // sent — covering absolute and scheme-relative URLs. func TestClient_RefusesCrossHostPath(t *testing.T) { t.Parallel()

var reached bool
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
    reached = true
    w.WriteHeader(http.StatusOK)
}))
defer other.Close()

c := NewClientWithBaseURL("secret-token", "https://api.example")

for _, path := range []string{other.URL + "/leak", "//evil.example/x", "https://evil.example/x"} { resp, err := c.Get(context.Background(), path) if err == nil { if resp != nil { _ = resp.Body.Close() } t.Errorf("Get(%q) = nil error, want cross-host rejection", path) } } if reached { t.Fatal("request reached another host; the bearer must not be sent off-origin") } }

// TestClient_RefusesCrossHostRedirect verifies a backend redirect to another // host is refused rather than followed with the bearer. func TestClient_RefusesCrossHostRedirect(t *testing.T) { t.Parallel()

var reached bool var leakedAuth string other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true leakedAuth = r.Header.Get("Authorization") w.WriteHeader(http.StatusOK) })) defer other.Close()

base := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, other.URL+"/leak", http.StatusFound) })) defer base.Close()

client := NewClientWithBaseURL("secret-token", base.URL) resp, err := client.Get(context.Background(), "/start") if err == nil { if resp != nil { _ = resp.Body.Close() } t.Fatal("expected cross-host redirect to be refused") } if reached { t.Fatalf("request reached the other host (Authorization=%q); bearer must not follow a cross-host redirect", leakedAuth) } }