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:
2026-08-17 13:51:00 +01:00
parent 99f0c844f0
commit 8258b3d99a
11 changed files with 881 additions and 36 deletions
+11 -1
View File
@@ -169,6 +169,14 @@ You can still use the non-prefixed environment variables in the spirit of the ba
| `HEALTHCHECK_GRAPHQL_URL` | The URL to check the health of the graphql endpoint | `` | | `HEALTHCHECK_GRAPHQL_URL` | The URL to check the health of the graphql endpoint | `` |
| `JWT_USER_CLAIM_PATH` | Path to the user claim in the JWT token | `` | | `JWT_USER_CLAIM_PATH` | Path to the user claim in the JWT token | `` |
| `JWT_ROLE_CLAIM_PATH` | Path to the role claim in the JWT token | `` | | `JWT_ROLE_CLAIM_PATH` | Path to the role claim in the JWT token | `` |
| `JWT_VERIFY_SIGNATURE` | Verify the JWT signature before trusting its claims (see [Caching](#caching)) | `false` |
| `JWT_SECRET` | HMAC shared secret used to verify the signature (HS256/HS384/HS512) | `` |
| `JWT_PUBLIC_KEY` | PEM public key used to verify the signature (RSA or ECDSA), inline or a file path | `` |
| `JWT_JWKS_URL` | JWKS endpoint used to verify the signature, with automatic key rotation | `` |
| `JWT_SIGNING_METHODS` | Comma-separated list of allowed algorithms, for example `RS256,ES256` | `` (allows the whole key family) |
| `JWT_ISSUER` | Expected `iss` claim, checked only when set | `` |
| `JWT_AUDIENCE` | Expected `aud` claim, checked only when set | `` |
| `JWT_LEEWAY_SECONDS` | Clock-skew tolerance, in seconds, for the `exp` and `nbf` checks | `0` |
| `ROLE_FROM_HEADER` | Header name to extract the role from | `` | | `ROLE_FROM_HEADER` | Header name to extract the role from | `` |
| `ROLE_RATE_LIMIT` | Enable request rate limiting based on role| `false` | | `ROLE_RATE_LIMIT` | Enable request rate limiting based on role| `false` |
| `ENABLE_GLOBAL_CACHE` | Enable the cache | `false` | | `ENABLE_GLOBAL_CACHE` | Enable the cache | `false` |
@@ -379,7 +387,9 @@ You can then start using the cache by setting the `ENABLE_GLOBAL_CACHE` or `ENAB
- Identical queries with different roles are cached separately - Identical queries with different roles are cached separately
- This keeps distinct identities from sharing a cache entry - This keeps distinct identities from sharing a cache entry
**Note on the security scope of this isolation**: The proxy decodes the JWT payload for the user ID and role, but it does not verify the JWT signature. A cache hit also returns before the request reaches the upstream backend's own authorization check. Treat the user/role split in the cache key as isolation between honest clients, not as an authorization boundary. Do not rely on it to stop a forged token from reaching another user's cached data. Cache keys include user context by default; keep this enabled in multi-user applications. **Note on the security scope of this isolation**: By default, the proxy decodes the JWT payload for the user ID and role, but it does not verify the JWT signature. A cache hit also returns before the request reaches the upstream backend's own authorization check. Treat the user/role split in the cache key as isolation between honest clients, not as an authorization boundary, unless you enable signature verification. Do not rely on the unverified split alone to stop a forged token from reaching another user's cached data. Cache keys include user context by default; keep this enabled in multi-user applications.
**Verifying the JWT signature**: Set `JWT_VERIFY_SIGNATURE=true` and configure exactly one key source: `JWT_SECRET`, `JWT_PUBLIC_KEY`, or `JWT_JWKS_URL`. The proxy then checks the token signature before it trusts any claim. It rejects a request that carries an invalid, expired, or algorithm-mismatched token with a `401` response. A request with no `Authorization` header still stays anonymous. It is not rejected. This setting is off by default, so an upgrade with no configuration change keeps today's behaviour. A misconfigured key source, such as zero or more than one of the three sources above, stops the proxy at startup instead of running half-configured.
Example: Example:
```graphql ```graphql
+33 -8
View File
@@ -15,31 +15,56 @@ const defaultValue = "-"
var emptyMetrics = map[string]string{} var emptyMetrics = map[string]string{}
func extractClaimsFromJWTHeader(authorization string) (usr, role string) { // extractClaimsFromJWTHeader extracts the user ID and role claim from an
// Authorization header value.
//
// When cfg.Client.JWTVerifier is set (JWT_VERIFY_SIGNATURE=true), the token
// signature is checked first; a signature, expiry, or algorithm failure
// returns a non-nil err and the caller must not trust usr/role (this is the
// fix for GHSA-9gqw-h2rw-44wv, where a forged token with a guessed claim
// could otherwise read another user's cached response).
//
// When JWTVerifier is nil (the default), this falls back to the legacy
// path: the payload is decoded without checking its signature, exactly as
// before this field existed. err is always nil on that path, matching
// pre-verification behaviour byte for byte.
func extractClaimsFromJWTHeader(authorization string) (usr, role string, err error) {
usr, role = defaultValue, defaultValue usr, role = defaultValue, defaultValue
if cfg.Client.JWTVerifier != nil {
claimMap, verr := cfg.Client.JWTVerifier.Verify(authorization)
if verr != nil {
handleError("JWT verification failed", map[string]any{"token": maskToken(authorization)})
return defaultValue, defaultValue, verr
}
usr = extractClaim(claimMap, cfg.Client.JWTUserClaimPath, "user id")
role = extractClaim(claimMap, cfg.Client.JWTRoleClaimPath, "role")
return usr, role, nil
}
tokenParts := strings.SplitN(authorization, ".", 3) tokenParts := strings.SplitN(authorization, ".", 3)
if len(tokenParts) != 3 { if len(tokenParts) != 3 {
handleError("Can't split the token", map[string]any{"token": maskToken(authorization)}) handleError("Can't split the token", map[string]any{"token": maskToken(authorization)})
return return usr, role, nil
} }
claim, err := base64.RawURLEncoding.DecodeString(tokenParts[1]) claim, decErr := base64.RawURLEncoding.DecodeString(tokenParts[1])
if err != nil { if decErr != nil {
handleError("Can't decode the token", map[string]any{"token": maskToken(authorization)}) handleError("Can't decode the token", map[string]any{"token": maskToken(authorization)})
return return usr, role, nil
} }
var claimMap map[string]any var claimMap map[string]any
if err = json.Unmarshal(claim, &claimMap); err != nil { if jsonErr := json.Unmarshal(claim, &claimMap); jsonErr != nil {
handleError("Can't unmarshal the claim", map[string]any{"token": maskToken(authorization)}) handleError("Can't unmarshal the claim", map[string]any{"token": maskToken(authorization)})
return return usr, role, nil
} }
usr = extractClaim(claimMap, cfg.Client.JWTUserClaimPath, "user id") usr = extractClaim(claimMap, cfg.Client.JWTUserClaimPath, "user id")
role = extractClaim(claimMap, cfg.Client.JWTRoleClaimPath, "role") role = extractClaim(claimMap, cfg.Client.JWTRoleClaimPath, "role")
return return usr, role, nil
} }
func extractClaim(claimMap map[string]any, claimPath, name string) string { func extractClaim(claimMap map[string]any, claimPath, name string) string {
+2 -1
View File
@@ -73,7 +73,8 @@ func (suite *Tests) Test_extractClaimsFromJWTHeader() {
if len(tt.jwt_role_path) > 0 { if len(tt.jwt_role_path) > 0 {
cfg.Client.JWTRoleClaimPath = tt.jwt_role_path cfg.Client.JWTRoleClaimPath = tt.jwt_role_path
} }
gotUsr, gotRole := extractClaimsFromJWTHeader(tt.args.authorization) gotUsr, gotRole, err := extractClaimsFromJWTHeader(tt.args.authorization)
suite.NoError(err, "Unexpected verification error")
suite.Equal(tt.wantUsr, gotUsr, "Unexpected user ID") suite.Equal(tt.wantUsr, gotUsr, "Unexpected user ID")
suite.Equal(tt.wantRole, gotRole, "Unexpected role") suite.Equal(tt.wantRole, gotRole, "Unexpected role")
}) })
+4
View File
@@ -3,6 +3,7 @@ module github.com/lukaszraczylo/graphql-monitoring-proxy
go 1.25.0 go 1.25.0
require ( require (
github.com/MicahParks/keyfunc/v3 v3.8.1
github.com/VictoriaMetrics/metrics v1.44.0 github.com/VictoriaMetrics/metrics v1.44.0
github.com/alicebob/miniredis/v2 v2.33.0 github.com/alicebob/miniredis/v2 v2.33.0
github.com/avast/retry-go/v4 v4.7.0 github.com/avast/retry-go/v4 v4.7.0
@@ -10,6 +11,7 @@ require (
github.com/gofiber/contrib/v3/websocket v1.2.0 github.com/gofiber/contrib/v3/websocket v1.2.0
github.com/gofiber/fiber/v3 v3.3.0 github.com/gofiber/fiber/v3 v3.3.0
github.com/gofrs/flock v0.13.0 github.com/gofrs/flock v0.13.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gookit/goutil v0.7.6 github.com/gookit/goutil v0.7.6
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
@@ -32,6 +34,7 @@ require (
) )
require ( require (
github.com/MicahParks/jwkset v0.11.1 // indirect
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
github.com/andybalholm/brotli v1.2.1 // indirect github.com/andybalholm/brotli v1.2.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
@@ -68,6 +71,7 @@ require (
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/term v0.44.0 // indirect golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.39.0 // indirect golang.org/x/text v0.39.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
+8
View File
@@ -1,3 +1,7 @@
github.com/MicahParks/jwkset v0.11.1 h1:Gr+6JeYoJ7twqyBGf8MXxj2NSCKPoX8RFMtey3GY9Nw=
github.com/MicahParks/jwkset v0.11.1/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.8.1 h1:VR3jlEs2wz1xGjvUUwUHeoB+eJ6gUnBrpnIByRrtj6g=
github.com/MicahParks/keyfunc/v3 v3.8.1/go.mod h1:LcorJ0sz2tZGvgZqIfaeyLkJmM+kxIfRDu8dFY0TAas=
github.com/VictoriaMetrics/metrics v1.44.0 h1:Fr8yqQSV+ZfYaDD/anqk1E8e9YPgfleSleJmAI0M0Tw= github.com/VictoriaMetrics/metrics v1.44.0 h1:Fr8yqQSV+ZfYaDD/anqk1E8e9YPgfleSleJmAI0M0Tw=
github.com/VictoriaMetrics/metrics v1.44.0/go.mod h1:xDM82ULLYCYdFRgQ2JBxi8Uf1+8En1So9YUwlGTOqTc= github.com/VictoriaMetrics/metrics v1.44.0/go.mod h1:xDM82ULLYCYdFRgQ2JBxi8Uf1+8En1So9YUwlGTOqTc=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
@@ -42,6 +46,8 @@ github.com/gofiber/utils/v2 v2.0.6 h1:7fXYy7nSsyqbH0GQUMtK4Kwjy4J7R5742VM7JsZxzO
github.com/gofiber/utils/v2 v2.0.6/go.mod h1:p7mAHAk3+oUK10ZX2xTw9fZQixb4hCg8SKd4IH2xroU= github.com/gofiber/utils/v2 v2.0.6/go.mod h1:p7mAHAk3+oUK10ZX2xTw9fZQixb4hCg8SKd4IH2xroU=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -160,6 +166,8 @@ golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 h1:ctPmKL12ZsoKAlmPUsoW70zEDiYF+/H6aLieXxgAU0k= google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 h1:ctPmKL12ZsoKAlmPUsoW70zEDiYF+/H6aLieXxgAU0k=
+276
View File
@@ -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())
}
}
+433
View File
@@ -0,0 +1,433 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
)
// ---------------------------------------------------------------------------
// Test helpers: in-test key generation and token signing.
// ---------------------------------------------------------------------------
// generateRSAKeyPair generates an RSA key pair and PEM-encodes the public
// key (PKIX, matching what jwt.ParseRSAPublicKeyFromPEM expects).
func generateRSAKeyPair(t *testing.T) (*rsa.PrivateKey, []byte) {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate RSA key: %v", err)
}
pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
t.Fatalf("failed to marshal RSA public key: %v", err)
}
return priv, pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
}
// generateECKeyPair generates an ECDSA (P-256) key pair and PEM-encodes the
// public key (PKIX, matching what jwt.ParseECPublicKeyFromPEM expects).
func generateECKeyPair(t *testing.T) (*ecdsa.PrivateKey, []byte) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("failed to generate EC key: %v", err)
}
pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
t.Fatalf("failed to marshal EC public key: %v", err)
}
return priv, pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
}
func signToken(t *testing.T, method jwt.SigningMethod, key any, claims jwt.MapClaims) string {
t.Helper()
signed, err := jwt.NewWithClaims(method, claims).SignedString(key)
if err != nil {
t.Fatalf("failed to sign token with %s: %v", method.Alg(), err)
}
return signed
}
// ---------------------------------------------------------------------------
// Case 1 & 2: HS256 valid token verifies; wrong secret is rejected.
// ---------------------------------------------------------------------------
func TestJWTVerifier_HMAC(t *testing.T) {
const secret = "correct-horse-battery-staple"
token := signToken(t, jwt.SigningMethodHS256, []byte(secret), jwt.MapClaims{
"sub": "alice",
"exp": time.Now().Add(time.Hour).Unix(),
})
tests := []struct {
name string
secret string
wantErr bool
}{
{name: "valid HS256 token verifies and returns claims", secret: secret, wantErr: false},
{name: "wrong HMAC secret is rejected", secret: "totally-different-secret", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v, err := newJWTVerifier(jwtVerifierConfig{Secret: tt.secret})
if err != nil {
t.Fatalf("newJWTVerifier() error = %v", err)
}
claims, err := v.Verify(token)
if tt.wantErr {
if err == nil {
t.Fatal("Verify() expected an error, got nil")
}
return
}
if err != nil {
t.Fatalf("Verify() unexpected error: %v", err)
}
if claims["sub"] != "alice" {
t.Fatalf("Verify() sub claim = %v, want %q", claims["sub"], "alice")
}
})
}
}
// ---------------------------------------------------------------------------
// Case 3: alg=none is always rejected.
// ---------------------------------------------------------------------------
func TestJWTVerifier_AlgNoneRejected(t *testing.T) {
v, err := newJWTVerifier(jwtVerifierConfig{Secret: "some-secret"})
if err != nil {
t.Fatalf("newJWTVerifier() error = %v", err)
}
// jwt.SigningMethodNone.Sign refuses to sign unless the caller passes the
// library's own unsafe sentinel key, which is exactly the point: this is
// the only way to construct a syntactically valid alg=none token at all.
noneToken := signToken(t, jwt.SigningMethodNone, jwt.UnsafeAllowNoneSignatureType, jwt.MapClaims{
"sub": "alice",
})
if _, err := v.Verify(noneToken); err == nil {
t.Fatal("Verify() accepted an alg=none token")
}
}
// ---------------------------------------------------------------------------
// Case 4: alg confusion. An RSA-configured verifier must reject an HS256
// token forged with the RSA public key's own PEM bytes as the HMAC secret.
// ---------------------------------------------------------------------------
func TestJWTVerifier_AlgConfusionRejected(t *testing.T) {
_, pubPEM := generateRSAKeyPair(t)
v, err := newJWTVerifier(jwtVerifierConfig{PublicKeyPEM: string(pubPEM)})
if err != nil {
t.Fatalf("newJWTVerifier() error = %v", err)
}
forged := signToken(t, jwt.SigningMethodHS256, pubPEM, jwt.MapClaims{
"sub": "victim",
"exp": time.Now().Add(time.Hour).Unix(),
})
if _, err := v.Verify(forged); err == nil {
t.Fatal("Verify() accepted an HS256 token signed with the RSA public key bytes as the HMAC secret (alg confusion)")
}
}
// ---------------------------------------------------------------------------
// Case 5: expired token is rejected; sufficient leeway accepts the same
// token.
// ---------------------------------------------------------------------------
func TestJWTVerifier_ExpiryAndLeeway(t *testing.T) {
const secret = "leeway-secret"
token := signToken(t, jwt.SigningMethodHS256, []byte(secret), jwt.MapClaims{
"sub": "alice",
"exp": time.Now().Add(-30 * time.Second).Unix(),
})
t.Run("expired token without leeway is rejected", func(t *testing.T) {
v, err := newJWTVerifier(jwtVerifierConfig{Secret: secret})
if err != nil {
t.Fatalf("newJWTVerifier() error = %v", err)
}
if _, err := v.Verify(token); err == nil {
t.Fatal("Verify() expected an error for an expired token, got nil")
}
})
t.Run("sufficient leeway accepts the same token", func(t *testing.T) {
v, err := newJWTVerifier(jwtVerifierConfig{Secret: secret, LeewaySeconds: 60})
if err != nil {
t.Fatalf("newJWTVerifier() error = %v", err)
}
if _, err := v.Verify(token); err != nil {
t.Fatalf("Verify() unexpected error with sufficient leeway: %v", err)
}
})
}
// ---------------------------------------------------------------------------
// Case 6: issuer mismatch and audience mismatch are both rejected.
// ---------------------------------------------------------------------------
func TestJWTVerifier_IssuerAndAudienceMismatch(t *testing.T) {
const secret = "iss-aud-secret"
t.Run("issuer mismatch is rejected", func(t *testing.T) {
token := signToken(t, jwt.SigningMethodHS256, []byte(secret), jwt.MapClaims{
"sub": "alice",
"iss": "wrong-issuer",
"exp": time.Now().Add(time.Hour).Unix(),
})
v, err := newJWTVerifier(jwtVerifierConfig{Secret: secret, Issuer: "expected-issuer"})
if err != nil {
t.Fatalf("newJWTVerifier() error = %v", err)
}
if _, err := v.Verify(token); err == nil {
t.Fatal("Verify() expected an issuer-mismatch error, got nil")
}
})
t.Run("audience mismatch is rejected", func(t *testing.T) {
token := signToken(t, jwt.SigningMethodHS256, []byte(secret), jwt.MapClaims{
"sub": "alice",
"aud": "wrong-audience",
"exp": time.Now().Add(time.Hour).Unix(),
})
v, err := newJWTVerifier(jwtVerifierConfig{Secret: secret, Audience: "expected-audience"})
if err != nil {
t.Fatalf("newJWTVerifier() error = %v", err)
}
if _, err := v.Verify(token); err == nil {
t.Fatal("Verify() expected an audience-mismatch error, got nil")
}
})
}
// ---------------------------------------------------------------------------
// Supplementary: both asymmetric families (RSA and EC) round-trip
// correctly, and a token from one asymmetric algorithm is rejected by a
// verifier pinned to the other via JWT_SIGNING_METHODS.
// ---------------------------------------------------------------------------
func TestJWTVerifier_AsymmetricFamilies(t *testing.T) {
t.Run("RS256 valid token verifies against an RSA public key", func(t *testing.T) {
priv, pubPEM := generateRSAKeyPair(t)
token := signToken(t, jwt.SigningMethodRS256, priv, jwt.MapClaims{
"sub": "alice",
"exp": time.Now().Add(time.Hour).Unix(),
})
v, err := newJWTVerifier(jwtVerifierConfig{PublicKeyPEM: string(pubPEM)})
if err != nil {
t.Fatalf("newJWTVerifier() error = %v", err)
}
claims, err := v.Verify(token)
if err != nil {
t.Fatalf("Verify() unexpected error: %v", err)
}
if claims["sub"] != "alice" {
t.Fatalf("Verify() sub claim = %v, want %q", claims["sub"], "alice")
}
})
t.Run("ES256 valid token verifies against an EC public key", func(t *testing.T) {
priv, pubPEM := generateECKeyPair(t)
token := signToken(t, jwt.SigningMethodES256, priv, jwt.MapClaims{
"sub": "alice",
"exp": time.Now().Add(time.Hour).Unix(),
})
v, err := newJWTVerifier(jwtVerifierConfig{PublicKeyPEM: string(pubPEM)})
if err != nil {
t.Fatalf("newJWTVerifier() error = %v", err)
}
claims, err := v.Verify(token)
if err != nil {
t.Fatalf("Verify() unexpected error: %v", err)
}
if claims["sub"] != "alice" {
t.Fatalf("Verify() sub claim = %v, want %q", claims["sub"], "alice")
}
})
t.Run("ES256 token rejected by a verifier pinned to RS256 only", func(t *testing.T) {
ecPriv, _ := generateECKeyPair(t)
_, rsaPubPEM := generateRSAKeyPair(t)
token := signToken(t, jwt.SigningMethodES256, ecPriv, jwt.MapClaims{
"sub": "alice",
"exp": time.Now().Add(time.Hour).Unix(),
})
v, err := newJWTVerifier(jwtVerifierConfig{PublicKeyPEM: string(rsaPubPEM), SigningMethods: "RS256"})
if err != nil {
t.Fatalf("newJWTVerifier() error = %v", err)
}
if _, err := v.Verify(token); err == nil {
t.Fatal("Verify() accepted an ES256 token against a verifier pinned to RS256 only")
}
})
}
// ---------------------------------------------------------------------------
// Startup validation: newJWTVerifier must fail loudly on a bad key-source
// count or an unparsable key, never boot half-configured.
// ---------------------------------------------------------------------------
func TestNewJWTVerifier_StartupValidation(t *testing.T) {
_, rsaPubPEM := generateRSAKeyPair(t)
tests := []struct {
name string
cfg jwtVerifierConfig
wantErr bool
}{
{name: "zero key sources is a fatal error", cfg: jwtVerifierConfig{}, wantErr: true},
{
name: "two key sources is a fatal error",
cfg: jwtVerifierConfig{Secret: "s", PublicKeyPEM: string(rsaPubPEM)},
wantErr: true,
},
{name: "exactly one key source is valid", cfg: jwtVerifierConfig{Secret: "s"}, wantErr: false},
{
name: "signing method outside the key's family is a fatal error",
cfg: jwtVerifierConfig{Secret: "s", SigningMethods: "RS256"},
wantErr: true,
},
{
name: "unparsable PEM is a fatal error",
cfg: jwtVerifierConfig{PublicKeyPEM: "-----BEGIN PUBLIC KEY-----\nnot-a-real-key\n-----END PUBLIC KEY-----"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := newJWTVerifier(tt.cfg)
if (err != nil) != tt.wantErr {
t.Fatalf("newJWTVerifier() error = %v, wantErr = %v", err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// Case 7: verification OFF (JWTVerifier nil) preserves the legacy,
// unverified decode path byte for byte -- required for a no-config-change
// upgrade to be default-preserving.
// ---------------------------------------------------------------------------
func TestExtractClaimsFromJWTHeader_VerifyOffBackwardCompat(t *testing.T) {
cfgMutex.Lock()
if cfg == nil {
cfg = &config{}
}
if cfg.Logger == nil {
cfg.Logger = libpack_logger.New()
}
origClient := cfg.Client
cfg.Client.JWTVerifier = nil
cfg.Client.JWTUserClaimPath = "sub"
cfg.Client.JWTRoleClaimPath = ""
cfgMutex.Unlock()
t.Cleanup(func() {
cfgMutex.Lock()
cfg.Client = origClient
cfgMutex.Unlock()
})
// An unsigned, forged token. With verification off, extractClaimsFromJWTHeader
// must still decode it (today's behaviour) instead of erroring.
forged := signToken(t, jwt.SigningMethodNone, jwt.UnsafeAllowNoneSignatureType, jwt.MapClaims{
"sub": "mallory",
})
usr, _, err := extractClaimsFromJWTHeader(forged)
if err != nil {
t.Fatalf("extractClaimsFromJWTHeader() unexpected error with verification off: %v", err)
}
if usr != "mallory" {
t.Fatalf("extractClaimsFromJWTHeader() usr = %q, want %q (legacy decode must still work)", usr, "mallory")
}
}
// ---------------------------------------------------------------------------
// Case 8: verification ON, a forged token sharing a victim's "sub" claim but
// signed with the wrong secret must make extractUserInfo return a non-nil
// error -- never the victim's identity -- so the caller 401s the request
// instead of letting it read (or write) the victim's cache entry.
// ---------------------------------------------------------------------------
func TestExtractUserInfo_ForgedTokenNeverReachesVictimCacheKey(t *testing.T) {
const secret = "cache-isolation-secret"
cfgMutex.Lock()
if cfg == nil {
cfg = &config{}
}
if cfg.Logger == nil {
cfg.Logger = libpack_logger.New()
}
origClient := cfg.Client
verifier, err := newJWTVerifier(jwtVerifierConfig{Secret: secret})
if err != nil {
cfgMutex.Unlock()
t.Fatalf("newJWTVerifier() error = %v", err)
}
cfg.Client.JWTVerifier = verifier
cfg.Client.JWTUserClaimPath = "sub"
cfgMutex.Unlock()
t.Cleanup(func() {
cfgMutex.Lock()
cfg.Client = origClient
cfgMutex.Unlock()
})
// Same "sub" as a legitimate victim, but signed with the wrong secret.
// If extractUserInfo ever returned "victim" here, the forged request
// would land in the victim's own per-user cache bucket.
forged := signToken(t, jwt.SigningMethodHS256, []byte("wrong-secret"), jwt.MapClaims{
"sub": "victim",
})
app := fiber.New()
var capturedErr error
var capturedUserID, capturedRole string
app.Get("/", func(c fiber.Ctx) error {
capturedUserID, capturedRole, capturedErr = extractUserInfo(c)
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Authorization", "Bearer "+forged)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 0})
if err != nil {
t.Fatalf("app.Test: %v", err)
}
_ = resp.Body.Close()
if capturedErr == nil {
t.Fatal("extractUserInfo() expected a verification error for a forged token, got nil")
}
if capturedUserID != defaultValue || capturedRole != defaultValue {
t.Fatalf("extractUserInfo() on verify failure = (%q, %q), want (%q, %q) so a forged token never maps to the victim's cache bucket",
capturedUserID, capturedRole, defaultValue, defaultValue)
}
}
+52
View File
@@ -273,6 +273,22 @@ func parseConfig() {
os.Exit(1) os.Exit(1)
} }
// JWT signature verification (GHSA-9gqw-h2rw-44wv): read here, next to the
// claim-path variables, but the verifier itself is built further down
// (after c.Logger exists) so a fatal misconfiguration can be logged
// through the normal logger, matching the other fatal-config-error
// patterns in this function. Off by default -- JWT_VERIFY_SIGNATURE=false
// or unset keeps today's unverified claim decode (see
// extractClaimsFromJWTHeader in details.go) byte-for-byte unchanged.
jwtVerifySignature := getDetailsFromEnv("JWT_VERIFY_SIGNATURE", false)
jwtSecret := getDetailsFromEnv("JWT_SECRET", "")
jwtPublicKey := getDetailsFromEnv("JWT_PUBLIC_KEY", "")
jwtJWKSURL := getDetailsFromEnv("JWT_JWKS_URL", "")
jwtSigningMethods := getDetailsFromEnv("JWT_SIGNING_METHODS", "")
jwtIssuer := getDetailsFromEnv("JWT_ISSUER", "")
jwtAudience := getDetailsFromEnv("JWT_AUDIENCE", "")
jwtLeewaySeconds := getDetailsFromEnv("JWT_LEEWAY_SECONDS", 0)
c.Client.RoleFromHeader = getDetailsFromEnv("ROLE_FROM_HEADER", "") c.Client.RoleFromHeader = getDetailsFromEnv("ROLE_FROM_HEADER", "")
c.Client.RoleRateLimit = getDetailsFromEnv("ROLE_RATE_LIMIT", false) c.Client.RoleRateLimit = getDetailsFromEnv("ROLE_RATE_LIMIT", false)
// In-memory cache // In-memory cache
@@ -665,6 +681,42 @@ func parseConfig() {
// Note: RPS tracker is initialized in main() with context for graceful shutdown // Note: RPS tracker is initialized in main() with context for graceful shutdown
// Initialize JWT signature verification (GHSA-9gqw-h2rw-44wv). The proxy
// derives per-user cache keys from JWT claims (see details.go); those
// claims were previously trusted without checking the token's signature,
// so a forged token with a guessed claim could read another user's
// cached response. A misconfigured key source here is fatal: the proxy
// must never boot half-configured, trusting claims it cannot actually
// verify.
if jwtVerifySignature {
verifier, err := newJWTVerifier(jwtVerifierConfig{
Secret: jwtSecret,
PublicKeyPEM: jwtPublicKey,
JWKSURL: jwtJWKSURL,
SigningMethods: jwtSigningMethods,
Issuer: jwtIssuer,
Audience: jwtAudience,
LeewaySeconds: jwtLeewaySeconds,
})
if err != nil {
cfg.Logger.Error(&libpack_logging.LogMessage{
Message: "Failed to initialize JWT signature verification",
Pairs: map[string]any{"error": err.Error()},
})
if ifNotInTest() {
fmt.Fprintln(os.Stderr, "❌ CRITICAL ERROR: JWT_VERIFY_SIGNATURE is enabled but misconfigured")
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
} else {
cfg.Client.JWTVerifier = verifier
cfg.Logger.Info(&libpack_logging.LogMessage{
Message: "JWT signature verification enabled",
})
}
}
// Load rate limit configuration with improved error handling // Load rate limit configuration with improved error handling
if err := loadRatelimitConfig(); err != nil { if err := loadRatelimitConfig(); err != nil {
// Log the error with clear guidance // Log the error with clear guidance
+8 -1
View File
@@ -713,7 +713,14 @@ func performProxyRequest(c fiber.Ctx, proxyURL string) error {
// reached without going through handleCaching (direct callers). // reached without going through handleCaching (direct callers).
cacheKey, _ := c.Locals("query_cache_hash").(string) cacheKey, _ := c.Locals("query_cache_hash").(string)
if cacheKey == "" { if cacheKey == "" {
userID, userRole := extractUserInfo(c) // Error intentionally ignored: the primary rejection for an
// invalid/forged token already happened in processGraphQLRequest
// (it 401s before the request ever reaches here). On a verify
// failure extractUserInfo returns ("-", "-", err), so a forged
// token still only maps into the shared anonymous cache bucket
// below, never into a genuine, verified user's own bucket
// (GHSA-9gqw-h2rw-44wv).
userID, userRole, _ := extractUserInfo(c)
cacheKey = libpack_cache.CalculateHash(c, userID, userRole) cacheKey = libpack_cache.CalculateHash(c, userID, userRole)
} }
+34 -10
View File
@@ -207,7 +207,11 @@ func handleWebSocketOrDefault(c fiber.Ctx) error {
if IsWebSocketRequest(c) { if IsWebSocketRequest(c) {
wsp := GetWebSocketProxy() wsp := GetWebSocketProxy()
if wsp != nil { if wsp != nil {
extractedUserID, extractedRoleName := extractUserInfo(c) extractedUserID, extractedRoleName, authErr := extractUserInfo(c)
if authErr != nil {
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
return c.Status(fiber.StatusUnauthorized).SendString("Invalid or expired authorization token")
}
if checkIfUserIsBanned(c, extractedUserID) { if checkIfUserIsBanned(c, extractedUserID) {
return c.Status(fiber.StatusForbidden).SendString("User is banned") return c.Status(fiber.StatusForbidden).SendString("User is banned")
@@ -378,7 +382,11 @@ func processGraphQLRequest(c fiber.Ctx) error {
startTime := time.Now() startTime := time.Now()
// Extract user information and check permissions // Extract user information and check permissions
extractedUserID, extractedRoleName := extractUserInfo(c) extractedUserID, extractedRoleName, authErr := extractUserInfo(c)
if authErr != nil {
cfg.Monitoring.Increment(libpack_monitoring.MetricsFailed, nil)
return c.Status(fiber.StatusUnauthorized).SendString("Invalid or expired authorization token")
}
// Check if user is banned // Check if user is banned
if checkIfUserIsBanned(c, extractedUserID) { if checkIfUserIsBanned(c, extractedUserID) {
@@ -424,15 +432,31 @@ func processGraphQLRequest(c fiber.Ctx) error {
return nil return nil
} }
// extractUserInfo extracts user ID and role from request headers // extractUserInfo extracts the user ID and role from request headers.
func extractUserInfo(c fiber.Ctx) (string, string) { //
extractedUserID := "-" // On a JWT signature verification failure (cfg.Client.JWTVerifier set and
extractedRoleName := "-" // the Authorization header carries an invalid/expired/forged token), it
// returns ("-", "-", err) without applying the RoleFromHeader override, so
// callers can 401 the request instead of proxying it under a wrong or
// forged identity (GHSA-9gqw-h2rw-44wv). A missing Authorization header is
// never an error; the request stays anonymous, exactly like before
// verification existed.
func extractUserInfo(c fiber.Ctx) (string, string, error) {
extractedUserID := defaultValue
extractedRoleName := defaultValue
// Extract from JWT if available // Extract from JWT if available. The claim-path check keeps the legacy
// (verify-off) gate unchanged; when a verifier IS configured, any
// Authorization header must still be checked even with no claim path
// set, otherwise JWT_VERIFY_SIGNATURE=true alone would never call
// Verify and the forged-token rejection below would never trigger.
if authorization := c.Get("Authorization"); authorization != "" && if authorization := c.Get("Authorization"); authorization != "" &&
(len(cfg.Client.JWTUserClaimPath) > 0 || len(cfg.Client.JWTRoleClaimPath) > 0) { (len(cfg.Client.JWTUserClaimPath) > 0 || len(cfg.Client.JWTRoleClaimPath) > 0 || cfg.Client.JWTVerifier != nil) {
extractedUserID, extractedRoleName = extractClaimsFromJWTHeader(authorization) usr, role, err := extractClaimsFromJWTHeader(authorization)
if err != nil {
return defaultValue, defaultValue, err
}
extractedUserID, extractedRoleName = usr, role
} }
// Override role from header if configured // Override role from header if configured
@@ -442,7 +466,7 @@ func extractUserInfo(c fiber.Ctx) (string, string) {
} }
} }
return extractedUserID, extractedRoleName return extractedUserID, extractedRoleName, nil
} }
// handleCaching manages the caching logic for GraphQL requests // handleCaching manages the caching logic for GraphQL requests
+20 -15
View File
@@ -42,21 +42,26 @@ type config struct {
PerUserCacheDisabled bool // Disable per-user cache isolation (SECURITY RISK - not recommended) PerUserCacheDisabled bool // Disable per-user cache isolation (SECURITY RISK - not recommended)
} }
Client struct { Client struct {
GQLClient *graphql.BaseClient GQLClient *graphql.BaseClient
FastProxyClient *fasthttp.Client FastProxyClient *fasthttp.Client
JWTUserClaimPath string JWTUserClaimPath string
JWTRoleClaimPath string JWTRoleClaimPath string
RoleFromHeader string // JWTVerifier verifies JWT signatures before extractClaimsFromJWTHeader
proxy string // trusts a claim from the token (GHSA-9gqw-h2rw-44wv). nil (the default)
ClientTimeout int // means JWT_VERIFY_SIGNATURE is off and claims are decoded unverified,
MaxConnsPerHost int // exactly like before this field existed.
ReadTimeout int JWTVerifier *JWTVerifier
WriteTimeout int RoleFromHeader string
MaxIdleConnDuration int proxy string
RetryBaseDelayMs int // initial backoff delay in milliseconds (default 500) ClientTimeout int
RetryMaxDelayMs int // maximum backoff delay in milliseconds (default 10000) MaxConnsPerHost int
RoleRateLimit bool ReadTimeout int
DisableTLSVerify bool WriteTimeout int
MaxIdleConnDuration int
RetryBaseDelayMs int // initial backoff delay in milliseconds (default 500)
RetryMaxDelayMs int // maximum backoff delay in milliseconds (default 10000)
RoleRateLimit bool
DisableTLSVerify bool
} }
Server struct { Server struct {
HostGraphQL string HostGraphQL string