cli: reject non-origin-relative paths in `entire api` (token-leak fix) · Entire

cli: reject non-origin-relative paths in entire api (token-leak fix)

91b2d0b→main·

Soph·2w ago·2 files·+42 added/-0 removed

entire api <path> resolves against the backend origin via url.ResolveReference, and the bearer transport attaches Authorization to the result. An absolute (https://evil/…) or scheme-relative (//evil/…) path replaces the host, so the request — with the Entire token — would be sent to an attacker-controlled host (over plain http too, bypassing the base-URL TLS check).

Validate the expanded path with validateAPIPath before any request: reject anything carrying its own scheme or host. Verified a pre-fix build sent the bearer to example.com; post-fix it's refused.

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

Sessions

af35631dfc02View transcript

Changes

2

81 unmodified lines

82
83
84
85
86
87
88
89
90
49 unmodified lines

140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161

81 unmodified lines

if err != nil {
        return err
    }
    if err := validateAPIPath(path); err != nil {
        return err
    }

fields, err := buildAPIFields(f.rawFields, f.typedFields)
    if err != nil {
49 unmodified lines

}
}

// validateAPIPath rejects anything that isn't origin-relative. The path is
// resolved against the backend origin via url.ResolveReference, which lets an
// absolute ("https://evil/…") or scheme-relative ("//evil/…") value replace the
// host — and the bearer transport would then send the Entire token there. So a
// path carrying its own scheme or host is refused before any request is made.
func validateAPIPath(path string) error {
    u, err := url.Parse(path)
    if err != nil {
        return fmt.Errorf("invalid path %q: %w", path, err)
    }
    if u.Scheme != "" || u.Host != "" {
        return fmt.Errorf("path must be origin-relative (e.g. /api/v1/…), not a cross-host or absolute URL: %q", path)
    }
    return nil
}

// expandAPIPlaceholders fills {owner}/{repo}/{repo_id} from the current repo.
// Resolution is lazy: {owner}/{repo} need only the git remote; {repo_id} costs
// a control-plane mirror lookup, so it's only done when actually referenced.

Mcmd/entire/cli/api_cmd.go+19

116 unmodified lines

117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145

116 unmodified lines

}
}

func TestValidateAPIPath(t *testing.T) {
    t.Parallel()

// Origin-relative paths are allowed.
    for _, ok := range []string{"/api/v1/clusters", "/api/v1/me/recap?repo=01K", "api/v1/x", "/"} {
        if err := validateAPIPath(ok); err != nil {
            t.Errorf("validateAPIPath(%q) = %v, want nil", ok, err)
        }
    }
    // Absolute and scheme-relative URLs must be refused — they'd redirect the
    // bearer token to another host via url.ResolveReference.
    for _, bad := range []string{
        "https://evil.example/api/v1/x",
        "http://evil.example/x",
        "//evil.example/x",
        "https:/evil",
    } {
        if err := validateAPIPath(bad); err == nil {
            t.Errorf("validateAPIPath(%q) = nil, want rejection (token-leak vector)", bad)
        }
    }
}

func TestResolveAPIClient_UnknownTarget(t *testing.T) {
    t.Parallel()
    if _, err := resolveAPIClient(context.Background(), "banana", false); err == nil {