git-remote-entire: gate ENTIRE_TOKEN aud against cluster-trusted cores · Entire
git-remote-entire: gate ENTIRE_TOKEN aud against cluster-trusted cores
5a22394→main·
toothbrush·1mo ago·5 files·+283 added/-18 removed
Adversarial review flagged that the ENTIRE_TOKEN path turned the token's unverified aud claim directly into the STS exchange host (SSRF / token exfiltration) and permitted cleartext http exchange.
Fixes:
- CoreURLFromEnvToken now enforces a strict origin: https scheme, host present, no userinfo/path/query/fragment. http (cleartext) and richer URLs are hard errors.
- Before exchanging, verify the aud is one of the cores the target cluster advertises at /.well-known/entire-cluster.json (fetched over TLS, keyed on the clone-URL host the user typed, reusing clusterdiscovery). A forged aud can no longer redirect the token to an attacker host; it aborts.
- Export clusterdiscovery.ResolveClusterCores for the cores-only lookup.
Trust model: claims are used only as a routing hint, constrained to TLS-vouched trusted cores; the core's STS remains the authoritative signature verifier. No client-side JWKS verification (matches the non-env login-context path).
Tests: strict aud validation (http/path/query/fragment/userinfo/opaque), plus the integrated gate against a fake well-known TLS server (trusted->ok, untrusted->abort, discovery failure->abort).
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
72c794f4cb83View transcript
Changes
5
- cmd
- entire/cli/auth
- Menv_token.go+47/-7
- Menv_token_test.go+33/-3
- git-remote-entire
- Mmain.go+44/-8
- Mmain_test.go+145
- entire/cli/auth
- internal/entireclient/clusterdiscovery
- Mresolve.go+14
// which is what STS routing keys on — so we read aud, not iss (iss may be a
// regional core that can't mint the cross-region exchange).
// The aud claim is URL-shaped (scheme://host[/path]). It may be a single
// string or an array (RFC 7519 §4.1.3); ParseClaims normalises both to a
// slice. The first URL-shaped audience wins. A token with no URL-shaped aud
// is rejected with a clear error rather than silently falling back to context
// resolution, so a misconfigured CI token fails loudly.
// SECURITY: the returned URL becomes the host the env token is POSTed to as a
// subject_token during exchange. ParseClaims does NOT verify the signature, so the
// audience is attacker-controlled if a forged token is injected. This
// function only enforces the *shape* of a safe endpoint (https, bare origin);
// the caller MUST additionally verify the URL is a trusted core for the target
// cluster (see clusterdiscovery.ResolveClusterCores) before exchanging, or a
// forged aud could redirect the token to an arbitrary host.
// Structural rules, all required:
// - the aud is a well-formed absolute URL,
// - scheme is https (no cleartext token exchange),
// - it carries a host and no userinfo, path, query, or fragment — entire
// cores are bare origins (https://core.example.com), so anything richer is
// either a misconfigured token or an attempt to smuggle a path/redirect.
// The aud claim may be a single string or an array (RFC 7519 §4.1.3);
// ParseClaims normalises both to a slice. Non-URL audiences (e.g. an OAuth
// client_id like "entire-cli") are skipped; the first URL-shaped audience is
// validated strictly. A token with no URL-shaped aud is rejected with a clear
// error rather than silently falling back to context resolution.
func CoreURLFromEnvToken(rawToken string) (string, error) {
claims, err := tokens.ParseClaims(rawToken)
if err != nil {
return "", fmt.Errorf("parse %s claims: %w", EnvTokenVar, err)
}
for _, aud := range claims.Audience {
if u, err := url.Parse(aud); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
return strings.TrimRight(aud, "/"), nil
}
perr := url.Parse(aud)
if perr != nil || u.Scheme == "" {
// Opaque (non-URL) audience such as an OAuth client_id — skip it.
continue
}
// URL-shaped: enforce the strict origin rules. A URL-shaped-but-invalid
// aud is a hard error (fail closed), never silently skipped.
return validateCoreAudience(u)
}
return "", fmt.Errorf("%s must be a login or sa-session JWT whose aud is the home-region URL; found no URL-shaped audience claim", EnvTokenVar)
}
// validateCoreAudience enforces that u is a safe entire-core origin and
// returns its canonical form (scheme://host, no trailing slash).
func validateCoreAudience(u *url.URL) (string, error) {
switch {
case u.Scheme != "https":
return "", fmt.Errorf("%s aud %q must use https; refusing to exchange the token over %s", EnvTokenVar, u.String(), u.Scheme)
case u.Host == "":
return "", fmt.Errorf("%s aud %q has no host", EnvTokenVar, u.String())
case u.User != nil:
return "", fmt.Errorf("%s aud %q must not contain userinfo", EnvTokenVar, u.String())
case u.Path != "" && u.Path != "/":
return "", fmt.Errorf("%s aud %q must be a bare origin with no path", EnvTokenVar, u.String())
case u.RawQuery != "":
return "", fmt.Errorf("%s aud %q must not contain query parameters", EnvTokenVar, u.String())
case u.Fragment != "":
return "", fmt.Errorf("%s aud %q must not contain a fragment", EnvTokenVar, u.String())
}
return strings.TrimRight(u.Scheme+"://"+u.Host, "/"), nil
}
``
### Testing functions
```go
// resolveCreds builds the repo-cred cache for the ENTIRE_TOKEN path.
// Split out of resolveCreds with explicit clusterHost/cacheDir params (no
// os.Getenv / DefaultCacheDir globals) so the trust gate below is unit-testable
// against a fake well-known server.
func resolveEnvTokenCreds(ctx context.Context, envToken, clusterHost, clusterBaseURL, cacheDir string, httpClient *http.Client) (*repocreds.Cache, error) {
coreURL, err := auth.CoreURLFromEnvToken(envToken)
if err != nil {
return nil, err //nolint:wrapcheck // CoreURLFromEnvToken already returns a user-facing, ENTIRE_TOKEN-prefixed error
}
cores, err := clusterdiscovery.ResolveClusterCores(ctx, cacheDir, clusterHost, httpClient, debuglog.Printf)
if err != nil {
return nil, err //nolint:wrapcheck // ResolveClusterCores returns a user-facing discovery error
}
if !coreTrusted(coreURL, cores) {
return nil, fmt.Errorf("%s aud %q is not a trusted core for cluster %s (advertised: %s); the token belongs to a different cluster",
auth.EnvTokenVar, coreURL, clusterHost, strings.Join(cores, ", "))
}
debuglog.Printf("authenticating via %s; core=%s", auth.EnvTokenVar, coreURL)
return repocreds.New(coreURL, clusterBaseURL, func(context.Context) (string, error) {
return envToken, nil
}, httpClient), nil
}
Tests
func TestResolveEnvTokenCreds_TrustedAudSucceeds(t *testing.T) {
t.Parallel()
const core = "https://core.us.entire.io"
srv, clusterHost := wellKnownServer(t, []string{core})
creds, err := resolveEnvTokenCreds(
t.Context(), makeTestJWT(t, core), clusterHost,
"https://cluster.example.com", t.TempDir(), srv.Client(),
)
if err != nil {
t.Fatalf("expected trusted aud to succeed, got: %v", err)
}
if creds == nil {
t.Fatal("expected non-nil creds for trusted aud")
}
}
This code ensures the auditing process is safe, checking conditions and validating previous steps before proceeding.