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).
- do() now rejects any request whose resolved host differs from the base URL's host (checked against the live c.baseURL, so it stays correct even when callers reassign baseURL).
- CheckRedirect refuses a redirect to a different host than the original.
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
cmd/entire/cli
api
- Mclient.go+46
Mclient_test.go+61
Mapi_cmd.go+3/-1
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) } }