mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-12 05:32:36 +00:00
fix(auth): verify JWT signature to prevent cache-key forgery (GHSA-9gqw-h2rw-44wv)
The proxy derived per-user GraphQL cache keys from JWT claims without verifying the token signature (CWE-345). A remote attacker could forge a token that carried a victim's claim value and read the victim's cached response. This change adds opt-in signature verification behind JWT_VERIFY_SIGNATURE. The flag defaults to false, so an upgrade without a config change keeps the previous delegate-to-upstream behavior byte for byte. When verification is on, the operator must set exactly one key source: JWT_SECRET, JWT_PUBLIC_KEY, or JWT_JWKS_URL. The verifier pins the accepted algorithms to the key family through WithValidMethods and a type-switched keyfunc, and it refuses alg=none. This closes the algorithm-confusion class of attack. A present but invalid token now returns 401. A missing Authorization header stays anonymous. Startup fails loudly when verification is on and the key source is absent or unreadable, so the proxy can never boot half-configured.
This commit is contained in:
+276
@@ -0,0 +1,276 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/MicahParks/keyfunc/v3"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// keyFamily identifies which class of signing algorithms a configured JWT
|
||||
// key source belongs to. Verify's keyfunc (see JWTVerifier.keyfn) enforces
|
||||
// that a token's algorithm belongs to the SAME family as the configured
|
||||
// key, so an HMAC-signed token can never be checked against an asymmetric
|
||||
// key and vice versa. This is what stops the alg-confusion attack fixed by
|
||||
// GHSA-9gqw-h2rw-44wv (e.g. an attacker forging an HS256 token whose HMAC
|
||||
// secret is the server's own RSA public key bytes).
|
||||
type keyFamily int
|
||||
|
||||
const (
|
||||
keyFamilyHMAC keyFamily = iota
|
||||
keyFamilyAsymmetric
|
||||
)
|
||||
|
||||
// hmacMethods and asymmetricMethods are the JWT "alg" header values
|
||||
// belonging to each keyFamily. "none" is deliberately absent from both
|
||||
// lists: WithValidMethods (see Verify) only accepts algorithms in one of
|
||||
// these lists, so a token with alg=none is rejected before JWTVerifier's
|
||||
// keyfunc is even called.
|
||||
var (
|
||||
hmacMethods = []string{"HS256", "HS384", "HS512"}
|
||||
asymmetricMethods = []string{"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"}
|
||||
)
|
||||
|
||||
// jwtVerifierConfig holds the resolved JWT_* environment settings
|
||||
// newJWTVerifier needs to build a JWTVerifier. main.go's parseConfig reads
|
||||
// the environment variables and populates this struct.
|
||||
type jwtVerifierConfig struct {
|
||||
// Secret is the HMAC shared secret (JWT_SECRET). Selects keyFamilyHMAC.
|
||||
Secret string
|
||||
// PublicKeyPEM is a PEM-encoded RSA or ECDSA public key (JWT_PUBLIC_KEY),
|
||||
// either inline (contains "-----BEGIN") or a path to a file containing
|
||||
// it. Selects keyFamilyAsymmetric.
|
||||
PublicKeyPEM string
|
||||
// JWKSURL is a JWKS endpoint (JWT_JWKS_URL) fetched and kept up to date
|
||||
// by keyfunc. Selects keyFamilyAsymmetric.
|
||||
JWKSURL string
|
||||
// SigningMethods is an optional comma-separated allowlist
|
||||
// (JWT_SIGNING_METHODS), e.g. "RS256,ES256". Empty means the full
|
||||
// algorithm family for the configured key source is allowed.
|
||||
SigningMethods string
|
||||
// Issuer, when set, is required to match the token's "iss" claim.
|
||||
Issuer string
|
||||
// Audience, when set, is required to be present in the token's "aud" claim.
|
||||
Audience string
|
||||
// LeewaySeconds is the clock-skew tolerance applied to exp/nbf checks.
|
||||
LeewaySeconds int
|
||||
}
|
||||
|
||||
// JWTVerifier verifies the signature of a JWT bearer token against exactly
|
||||
// one configured key source (an HMAC secret, a PEM public key, or a JWKS
|
||||
// endpoint), pinned to that source's algorithm family. Build one with
|
||||
// newJWTVerifier; a nil *JWTVerifier on config.Client (the default) means
|
||||
// signature verification is off and callers must use the legacy unverified
|
||||
// decode path (see details.go extractClaimsFromJWTHeader).
|
||||
type JWTVerifier struct {
|
||||
jwks keyfunc.Keyfunc
|
||||
hmacSecret []byte
|
||||
publicKey crypto.PublicKey
|
||||
family keyFamily
|
||||
allowedMethods []string
|
||||
issuer string
|
||||
audience string
|
||||
leeway time.Duration
|
||||
}
|
||||
|
||||
// newJWTVerifier builds a JWTVerifier from cfg. Exactly one of
|
||||
// cfg.Secret, cfg.PublicKeyPEM, or cfg.JWKSURL must be set; any other count
|
||||
// is a fatal configuration error, not a silent fallback, so the proxy never
|
||||
// starts half-configured (GHSA-9gqw-h2rw-44wv requires verification to
|
||||
// actually run once JWT_VERIFY_SIGNATURE is on).
|
||||
func newJWTVerifier(cfg jwtVerifierConfig) (*JWTVerifier, error) {
|
||||
sourcesConfigured := 0
|
||||
if cfg.Secret != "" {
|
||||
sourcesConfigured++
|
||||
}
|
||||
if cfg.PublicKeyPEM != "" {
|
||||
sourcesConfigured++
|
||||
}
|
||||
if cfg.JWKSURL != "" {
|
||||
sourcesConfigured++
|
||||
}
|
||||
if sourcesConfigured != 1 {
|
||||
return nil, fmt.Errorf("JWT_VERIFY_SIGNATURE requires exactly one of JWT_SECRET, JWT_PUBLIC_KEY, JWT_JWKS_URL, got %d configured", sourcesConfigured)
|
||||
}
|
||||
|
||||
v := &JWTVerifier{
|
||||
issuer: cfg.Issuer,
|
||||
audience: cfg.Audience,
|
||||
leeway: time.Duration(cfg.LeewaySeconds) * time.Second,
|
||||
}
|
||||
|
||||
switch {
|
||||
case cfg.Secret != "":
|
||||
v.family = keyFamilyHMAC
|
||||
v.hmacSecret = []byte(cfg.Secret)
|
||||
case cfg.PublicKeyPEM != "":
|
||||
v.family = keyFamilyAsymmetric
|
||||
pemBytes, err := resolvePEMSource(cfg.PublicKeyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read JWT_PUBLIC_KEY: %w", err)
|
||||
}
|
||||
pubKey, err := parsePublicKeyPEM(pemBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JWT_PUBLIC_KEY: %w", err)
|
||||
}
|
||||
v.publicKey = pubKey
|
||||
case cfg.JWKSURL != "":
|
||||
v.family = keyFamilyAsymmetric
|
||||
kf, err := keyfunc.NewDefault([]string{cfg.JWKSURL})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialise JWKS from JWT_JWKS_URL: %w", err)
|
||||
}
|
||||
v.jwks = kf
|
||||
}
|
||||
|
||||
allowed, err := resolveAllowedMethods(v.family, cfg.SigningMethods)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.allowedMethods = allowed
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// resolveAllowedMethods intersects the operator-configured
|
||||
// JWT_SIGNING_METHODS allowlist with the algorithm family of the
|
||||
// configured key. An empty allowlist means "allow the whole family". A
|
||||
// configured method outside the key's family (e.g. JWT_SECRET set together
|
||||
// with JWT_SIGNING_METHODS=RS256) is a fatal configuration error: it can
|
||||
// never be satisfied, since JWTVerifier.keyfn refuses to return an HMAC
|
||||
// secret for an asymmetric alg or vice versa.
|
||||
func resolveAllowedMethods(family keyFamily, signingMethods string) ([]string, error) {
|
||||
familyMethods := hmacMethods
|
||||
if family == keyFamilyAsymmetric {
|
||||
familyMethods = asymmetricMethods
|
||||
}
|
||||
|
||||
if strings.TrimSpace(signingMethods) == "" {
|
||||
return familyMethods, nil
|
||||
}
|
||||
|
||||
allowedInFamily := make(map[string]bool, len(familyMethods))
|
||||
for _, m := range familyMethods {
|
||||
allowedInFamily[m] = true
|
||||
}
|
||||
|
||||
configured := strings.Split(signingMethods, ",")
|
||||
allowed := make([]string, 0, len(configured))
|
||||
for _, m := range configured {
|
||||
m = strings.TrimSpace(m)
|
||||
if m == "" {
|
||||
continue
|
||||
}
|
||||
if !allowedInFamily[m] {
|
||||
return nil, fmt.Errorf("JWT_SIGNING_METHODS %q is not part of the configured key's algorithm family", m)
|
||||
}
|
||||
allowed = append(allowed, m)
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return nil, errors.New("JWT_SIGNING_METHODS resolved to no valid algorithms for the configured key")
|
||||
}
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
// resolvePEMSource returns the PEM bytes for JWT_PUBLIC_KEY. The value is
|
||||
// used inline when it already contains a PEM header, otherwise it is
|
||||
// treated as a path and read from disk.
|
||||
func resolvePEMSource(value string) ([]byte, error) {
|
||||
if strings.Contains(value, "-----BEGIN") {
|
||||
return []byte(value), nil
|
||||
}
|
||||
data, err := os.ReadFile(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading PEM file %q: %w", value, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// parsePublicKeyPEM parses pemBytes as either an RSA or an ECDSA public
|
||||
// key, trying RSA first. It returns an error if neither parses.
|
||||
func parsePublicKeyPEM(pemBytes []byte) (crypto.PublicKey, error) {
|
||||
if rsaKey, err := jwt.ParseRSAPublicKeyFromPEM(pemBytes); err == nil {
|
||||
return rsaKey, nil
|
||||
}
|
||||
if ecKey, err := jwt.ParseECPublicKeyFromPEM(pemBytes); err == nil {
|
||||
return ecKey, nil
|
||||
}
|
||||
return nil, errors.New("PEM does not contain a valid RSA or ECDSA public key")
|
||||
}
|
||||
|
||||
// Verify checks the signature, algorithm family, and standard claims (exp,
|
||||
// nbf, and the configured issuer/audience) of a bearer token taken from an
|
||||
// Authorization header value. It returns the token's claims only when every
|
||||
// check passes; on any failure it returns a non-nil error and the claims
|
||||
// MUST NOT be used, so callers never trust a claim from a token that failed
|
||||
// verification.
|
||||
func (v *JWTVerifier) Verify(authorization string) (map[string]any, error) {
|
||||
tokenString := stripBearerPrefix(authorization)
|
||||
|
||||
opts := []jwt.ParserOption{
|
||||
jwt.WithValidMethods(v.allowedMethods),
|
||||
jwt.WithLeeway(v.leeway),
|
||||
}
|
||||
if v.issuer != "" {
|
||||
opts = append(opts, jwt.WithIssuer(v.issuer))
|
||||
}
|
||||
if v.audience != "" {
|
||||
opts = append(opts, jwt.WithAudience(v.audience))
|
||||
}
|
||||
|
||||
parser := jwt.NewParser(opts...)
|
||||
|
||||
claims := jwt.MapClaims{}
|
||||
token, err := parser.ParseWithClaims(tokenString, claims, v.keyfn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jwt verification failed: %w", err)
|
||||
}
|
||||
if !token.Valid {
|
||||
return nil, errors.New("jwt verification failed: token is not valid")
|
||||
}
|
||||
|
||||
return map[string]any(claims), nil
|
||||
}
|
||||
|
||||
// stripBearerPrefix removes a case-insensitive "Bearer " prefix from an
|
||||
// Authorization header value, if present.
|
||||
func stripBearerPrefix(authorization string) string {
|
||||
const prefix = "bearer "
|
||||
if len(authorization) > len(prefix) && strings.EqualFold(authorization[:len(prefix)], prefix) {
|
||||
return authorization[len(prefix):]
|
||||
}
|
||||
return authorization
|
||||
}
|
||||
|
||||
// keyfn is the jwt.Keyfunc used by Verify to resolve the key for a parsed
|
||||
// token. It is the enforcement point for algorithm-family pinning (the
|
||||
// actual fix for GHSA-9gqw-h2rw-44wv): it identifies the concrete signing
|
||||
// method type of the token and refuses to return a key at all unless that
|
||||
// type belongs to v.family, so an HMAC secret is never handed back for an
|
||||
// asymmetric token and a public key is never handed back for an HMAC
|
||||
// token. Any other method, including SigningMethodNone, falls through to
|
||||
// the default case and is rejected.
|
||||
func (v *JWTVerifier) keyfn(token *jwt.Token) (any, error) {
|
||||
switch token.Method.(type) {
|
||||
case *jwt.SigningMethodHMAC:
|
||||
if v.family != keyFamilyHMAC {
|
||||
return nil, fmt.Errorf("unexpected alg %q for the configured key family", token.Method.Alg())
|
||||
}
|
||||
return v.hmacSecret, nil
|
||||
case *jwt.SigningMethodRSA, *jwt.SigningMethodRSAPSS, *jwt.SigningMethodECDSA:
|
||||
if v.family != keyFamilyAsymmetric {
|
||||
return nil, fmt.Errorf("unexpected alg %q for the configured key family", token.Method.Alg())
|
||||
}
|
||||
if v.jwks != nil {
|
||||
return v.jwks.Keyfunc(token)
|
||||
}
|
||||
return v.publicKey, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected alg %q", token.Method.Alg())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user