Skip to content

JWKS single-provider fallback bypasses issuer validation; JWKS URL fetch is unrestricted (SSRF to cloud metadata) (2 sub-items) #61

@hollanf

Description

@hollanf

Two issues in the JWT/JWKS auth path. (1) When only one JWT provider is configured, find_provider returns it regardless of the token's iss claim — and the subsequent iss check is skipped if provider.issuer is empty. A token minted by the operator's IdP for an unrelated tenant is accepted as a valid session with attacker-chosen claims. (2) The JWKS HTTP fetcher accepts arbitrary URLs with no scheme allow-list, no IP-literal filter, no redirect cap, and no DNS-rebinding defense — classic SSRF to cloud-metadata / link-local / RFC1918 endpoints.


1. Single-provider JWKS fallback + empty issuer = any token from that JWKS passes

File: nodedb/src/control/security/jwks/registry.rs:106, 150-153, 207-224

fn find_provider(&self, issuer: &str) -> Result<&JwtProviderConfig, JwtError> {
    if let Some(p) = self.providers.iter()
        .find(|p| !p.issuer.is_empty() && p.issuer == issuer) { return Ok(p); }
    // If only one provider is configured, use it regardless of issuer.
    if self.providers.len() == 1 { return Ok(&self.providers[0]); }
    Err(JwtError::InvalidIssuer)
}
...
if !provider.issuer.is_empty() && claims.iss != provider.issuer {
    return Err(JwtError::InvalidIssuer);
}

Two bugs composing:

  • find_provider returns the sole provider when only one is configured, ignoring the token's iss.
  • The guarded iss check at line 150 only fires when provider.issuer is non-empty. If the operator omits issuer = "..." from config (a common shape for "we only accept one IdP, no need"), validation is fully skipped.

Once find_provider returns, the signature is verified against the provider's JWKS, and then claims (sub, tenant_id, roles, is_superuser) are read straight from the token body into an AuthContext.

Exploitation path: a large IdP (Auth0, Cognito, Azure AD multi-tenant endpoints) publishes a shared JWKS across many customers. A token minted by any of those customers is signed by a key present in the same JWKS document. If the deployment's single configured provider points at that shared JWKS origin and the operator didn't set issuer, the deployment accepts tokens from unrelated customers of the same IdP — whose sub, tenant_id, and is_superuser claims the attacker fully controls.

Repro:

# config.toml
[[auth.jwt.providers]]
name     = "prod"
jwks_url = "https://attacker-owned-tenant.auth0.com/.well-known/jwks.json"
# issuer omitted
# Attacker signs a token against their own Auth0 tenant (same JWKS origin):
{
  "iss": "https://attacker-owned-tenant.auth0.com/",
  "sub": "admin@attacker",
  "tenant_id": 0,
  "is_superuser": true,
  "exp": <future>
}

nodedb accepts the token as a superuser of tenant 0.


2. JWKS fetcher is an unrestricted HTTP client — SSRF to link-local / metadata / RFC1918

File: nodedb/src/control/security/jwks/fetch.rs:49-80, registry refresh at registry.rs:42, 245

async fn fetch_jwks(url: &str) -> Result<Vec<VerificationKey>, JwksFetchError> {
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(10))
        .build()...?;
    let response = client.get(url).header("accept", "application/json").send().await...?;
    let status = response.status();
    ...
    let body = response.text().await...?;
    let jwks: JwksResponse = sonic_rs::from_str(&body)...?;

url comes from config (or the registry's on-demand re-fetch path) and is used verbatim:

  • No scheme allow-list (http allowed in addition to https).
  • No host allow-list.
  • No IP-literal filter — http://169.254.169.254/… is accepted.
  • No filter for RFC1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) or loopback / link-local.
  • No redirect cap or redirect-target revalidation.
  • No DNS-rebinding defense (name resolves once, subsequent refreshes may hit different IPs).
  • Failed parse errors carry the response body via error = %e in logs — reachable via SIEM / log sinks — so even when the JSON parse fails, the HTTP body's content is exfiltrated.

Exploitation paths:

  • AWS: http://169.254.169.254/latest/meta-data/iam/security-credentials/<role> → IAM credentials in the error-log body.
  • GCP: http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token (may require a header; still reachable).
  • Azure: http://169.254.169.254/metadata/identity/oauth2/token.
  • Internal admin services listening on 127.0.0.1:<port> or RFC1918 addresses.
  • Bypassing egress firewalls by pointing the node at internal-only resources.

Repro:

[[auth.jwt.providers]]
name     = "evil"
issuer   = ""
jwks_url = "http://169.254.169.254/latest/meta-data/iam/security-credentials/app-role"

On startup (or on first use of this provider), the fetch executes; the JWKS parse fails; the response body is logged via JwksFetchError::JsonParse(..) which includes body content → IAM credentials end up in the log pipeline.


Checklist

  • 1. Require issuer to be present and non-empty in JwtProviderConfig; reject config at load time otherwise. Remove the "only one provider → skip issuer" fallback, or at minimum require an explicit issuer_validation: "skip" flag to opt into it.
  • 2. Validate JWKS URL against: scheme ∈ {https}, host is not a literal IP, host resolves only to non-private / non-loopback / non-link-local addresses, follow ≤ 3 redirects with the same restriction applied per hop. Scrub response body from error messages.

Both items are independently verifiable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions