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:

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

// 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.