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:
@@ -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
@@ -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
@@ -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")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user