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

`aaeb854`→[main](/content/gh/entireio/cli/commits/main/index.html)· 
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)
    }
}  
```
