Bound GraphQL request cost to prevent alias-flooding DoS

The GraphQL endpoint built its gqlgen server with bare handler.New and
no limits, so a single request with thousands of aliased resolver calls
was parsed, validated, executed, and marshalled in full. Under load this
let an unauthenticated client drive excessive CPU and memory use against
POST /api/connect/v1/graphql and the console and trust endpoints, which
share the same constructor (GHSA-prh2-g8pv-m7p9).

Add configurable guards in the shared gqlutils.NewHandler: a parser
token limit rejects oversized queries at lex time before any execution,
a fixed complexity limit caps field-selection count, an LRU query cache
avoids repeated parsing, and field suggestions are disabled. The limits
flow from a new APIConfig.GraphQL section through server and api config
into all three GraphQL handlers, with PROBOD_API_GRAPHQL_* env vars and
Helm values exposed for per-environment tuning.

Defaults are sized with generous headroom over real traffic: the parser
token limit (15000) and complexity limit (2000) sit far above the
largest legitimate frontend query yet well below the proof-of-concept
flood, so normal usage is unaffected while floods are rejected cheaply.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-06-29 17:38:16 +02:00
parent 9f9ace2cb8
commit bf255b198c
18 changed files with 127 additions and 10 deletions

View File

@@ -75,6 +75,14 @@ spec:
value: ":{{ .Values.service.port }}"
- name: PROBOD_API_CORS_ALLOWED_ORIGINS
value: {{ join "," .Values.probo.cors.allowedOrigins | quote }}
- name: PROBOD_API_GRAPHQL_PARSER_TOKEN_LIMIT
value: {{ .Values.probo.graphql.parserTokenLimit | quote }}
- name: PROBOD_API_GRAPHQL_COMPLEXITY_LIMIT
value: {{ .Values.probo.graphql.complexityLimit | quote }}
- name: PROBOD_API_GRAPHQL_QUERY_CACHE_SIZE
value: {{ .Values.probo.graphql.queryCacheSize | quote }}
- name: PROBOD_API_GRAPHQL_DISABLE_SUGGESTION
value: {{ .Values.probo.graphql.disableSuggestion | quote }}
# PostgreSQL Database
- name: PROBOD_PG_ADDR
value: {{ printf "%s:%v" (include "probo.postgresql.host" .) (include "probo.postgresql.port" .) | quote }}

View File

@@ -111,6 +111,15 @@ probo:
allowedOrigins:
- "https://probo.example.com"
# GraphQL request-cost guards (application-layer DoS protection).
# Defaults are production-safe; tune only if a legitimate query is rejected
# or to enable complexity analysis. A value of 0 disables the guard.
graphql:
parserTokenLimit: 15000
complexityLimit: 2000
queryCacheSize: 1000
disableSuggestion: true
# Extra HTTP headers to add to responses
extraHeaderFields: {}
# X-Custom-Header: "custom-value"

View File

@@ -201,6 +201,20 @@ probo:
- "https://probo.example.com"
- "http://probo.example.com"
# GraphQL request-cost guards (application-layer DoS protection).
# A value of 0 disables the corresponding guard.
graphql:
# Maximum number of lexer tokens accepted per query. Oversized queries
# (e.g. alias flooding) are rejected at parse time before execution.
parserTokenLimit: 15000
# Maximum query complexity (field-selection count). 0 disables complexity
# analysis.
complexityLimit: 2000
# Size of the LRU cache of parsed query documents.
queryCacheSize: 1000
# Disable field suggestions on invalid queries.
disableSuggestion: true
# Show Probo branding
branding: true

2
go.mod
View File

@@ -12,6 +12,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.53.4
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.2
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.42.3
github.com/aws/aws-sdk-go-v2/service/ssm v1.69.3
github.com/brianvoe/gofakeit/v7 v7.15.0
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
@@ -60,7 +61,6 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.28 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.1.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssm v1.69.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.2 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.2 // indirect

View File

@@ -79,6 +79,12 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) {
AllowedOrigins: b.parseOriginsList(b.resolver.getEnvOrDefault("PROBOD_API_CORS_ALLOWED_ORIGINS", "http://localhost:8080")),
},
ExtraHeaderFields: make(map[string]string),
GraphQL: probodconfig.GraphQLConfig{
ParserTokenLimit: b.resolver.getEnvIntOrDefault("PROBOD_API_GRAPHQL_PARSER_TOKEN_LIMIT", 15000),
ComplexityLimit: b.resolver.getEnvIntOrDefault("PROBOD_API_GRAPHQL_COMPLEXITY_LIMIT", 2000),
QueryCacheSize: b.resolver.getEnvIntOrDefault("PROBOD_API_GRAPHQL_QUERY_CACHE_SIZE", 1000),
DisableSuggestion: b.resolver.getEnvBoolOrDefault("PROBOD_API_GRAPHQL_DISABLE_SUGGESTION", true),
},
},
Pg: probodconfig.PgConfig{
Addr: b.resolver.getEnvOrDefault("PROBOD_PG_ADDR", "localhost:5432"),

View File

@@ -146,6 +146,10 @@ func TestBuilder_Build_Defaults(t *testing.T) {
assert.Equal(t, ":8080", cfg.Probod.Api.Addr)
assert.Nil(t, cfg.Probod.Api.ProxyProtocol.TrustedProxies)
assert.Equal(t, []string{"http://localhost:8080"}, cfg.Probod.Api.Cors.AllowedOrigins)
assert.Equal(t, 15000, cfg.Probod.Api.GraphQL.ParserTokenLimit)
assert.Equal(t, 2000, cfg.Probod.Api.GraphQL.ComplexityLimit)
assert.Equal(t, 1000, cfg.Probod.Api.GraphQL.QueryCacheSize)
assert.True(t, cfg.Probod.Api.GraphQL.DisableSuggestion)
// PG config
assert.Equal(t, "localhost:5432", cfg.Probod.Pg.Addr)
@@ -295,6 +299,10 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
env["PROBOD_API_ADDR"] = "0.0.0.0:8080"
env["PROBOD_API_CORS_ALLOWED_ORIGINS"] = "https://app.example.com,https://admin.example.com"
env["PROBOD_API_PROXY_PROTOCOL_TRUSTED_PROXIES"] = "10.0.0.1,10.0.0.2"
env["PROBOD_API_GRAPHQL_PARSER_TOKEN_LIMIT"] = "20000"
env["PROBOD_API_GRAPHQL_COMPLEXITY_LIMIT"] = "5000"
env["PROBOD_API_GRAPHQL_QUERY_CACHE_SIZE"] = "2000"
env["PROBOD_API_GRAPHQL_DISABLE_SUGGESTION"] = "false"
// PG
env["PROBOD_PG_ADDR"] = "postgres.example.com:5432"
env["PROBOD_PG_USERNAME"] = "probo"
@@ -425,6 +433,10 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
assert.Equal(t, "0.0.0.0:8080", cfg.Probod.Api.Addr)
assert.Equal(t, []string{"10.0.0.1", "10.0.0.2"}, cfg.Probod.Api.ProxyProtocol.TrustedProxies)
assert.Equal(t, []string{"https://app.example.com", "https://admin.example.com"}, cfg.Probod.Api.Cors.AllowedOrigins)
assert.Equal(t, 20000, cfg.Probod.Api.GraphQL.ParserTokenLimit)
assert.Equal(t, 5000, cfg.Probod.Api.GraphQL.ComplexityLimit)
assert.Equal(t, 2000, cfg.Probod.Api.GraphQL.QueryCacheSize)
assert.False(t, cfg.Probod.Api.GraphQL.DisableSuggestion)
// PG
assert.Equal(t, "postgres.example.com:5432", cfg.Probod.Pg.Addr)
assert.Equal(t, "probo", cfg.Probod.Pg.Username)

View File

@@ -26,6 +26,7 @@ type (
TrustCenterConfig = probodconfig.TrustCenterConfig
APIConfig = probodconfig.APIConfig
CorsConfig = probodconfig.CorsConfig
GraphQLConfig = probodconfig.GraphQLConfig
ProxyProtocolConfig = probodconfig.ProxyProtocolConfig
AuthConfig = probodconfig.AuthConfig
OAuth2ServerConfig = probodconfig.OAuth2ServerConfig

View File

@@ -70,6 +70,7 @@ import (
"go.probo.inc/probo/pkg/riskmanagement"
"go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/server/trustedproxy"
"go.probo.inc/probo/pkg/slack"
"go.probo.inc/probo/pkg/thirdparty"
@@ -93,6 +94,12 @@ func New() *Implm {
BaseURL: "http://localhost:8080",
Api: APIConfig{
Addr: "localhost:8080",
GraphQL: GraphQLConfig{
ParserTokenLimit: 15000,
ComplexityLimit: 2000,
QueryCacheSize: 1000,
DisableSuggestion: true,
},
},
Pg: PgConfig{
Addr: "localhost:5432",
@@ -640,6 +647,12 @@ func (impl *Implm) Run(
ConnectorRegistry: defaultConnectorRegistry,
ProviderRegistry: providerRegistry,
BaseURL: baseURL,
GraphQLLimits: gqlutils.Limits{
ParserTokenLimit: impl.cfg.Api.GraphQL.ParserTokenLimit,
ComplexityLimit: impl.cfg.Api.GraphQL.ComplexityLimit,
QueryCacheSize: impl.cfg.Api.GraphQL.QueryCacheSize,
DisableSuggestion: impl.cfg.Api.GraphQL.DisableSuggestion,
},
CustomDomainCname: impl.cfg.CustomDomains.CnameTarget,
TokenSecret: impl.cfg.Auth.Cookie.Secret,

View File

@@ -22,9 +22,17 @@ type ProxyProtocolConfig struct {
TrustedProxies []string `json:"trusted-proxies"`
}
type GraphQLConfig struct {
ParserTokenLimit int `json:"parser-token-limit"`
ComplexityLimit int `json:"complexity-limit"`
QueryCacheSize int `json:"query-cache-size"`
DisableSuggestion bool `json:"disable-suggestion"`
}
type APIConfig struct {
Addr string `json:"addr"`
ProxyProtocol ProxyProtocolConfig `json:"proxy-protocol"`
Cors CorsConfig `json:"cors"`
ExtraHeaderFields map[string]string `json:"extra-header-fields"`
GraphQL GraphQLConfig `json:"graphql"`
}

View File

@@ -47,6 +47,7 @@ import (
mcp_v1 "go.probo.inc/probo/pkg/server/api/mcp/v1"
slack_v1 "go.probo.inc/probo/pkg/server/api/slack/v1"
trust_v1 "go.probo.inc/probo/pkg/server/api/trust/v1"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/slack"
"go.probo.inc/probo/pkg/thirdparty"
"go.probo.inc/probo/pkg/trust"
@@ -75,6 +76,7 @@ type (
ConnectorRegistry *connector.ConnectorRegistry
ProviderRegistry *provider.Registry
CustomDomainCname string
GraphQLLimits gqlutils.Limits
Logger *log.Logger
}
@@ -188,6 +190,7 @@ func NewServer(cfg Config) (*Server, error) {
cfg.Cookie,
cfg.TokenSecret,
cfg.BaseURL,
cfg.GraphQLLimits,
),
consoleHandler: console_v1.NewMux(
cfg.Logger.Named("console.v1"),
@@ -208,6 +211,7 @@ func NewServer(cfg Config) (*Server, error) {
cfg.CustomDomainCname,
cfg.ThirdParty,
cfg.RiskManagement,
cfg.GraphQLLimits,
),
cookieBannerHandler: cookiebanner_v1.NewMux(
cfg.Logger.Named("cookiebanner.v1"),
@@ -261,6 +265,7 @@ func NewServer(cfg Config) (*Server, error) {
_, err := cfg.Trust.GetByDomainName(ctx, host)
return err == nil
},
cfg.GraphQLLimits,
),
}, nil
}

View File

@@ -30,7 +30,7 @@ import (
"go.probo.inc/probo/pkg/server/gqlutils/directives/session"
)
func NewGraphQLHandler(svc *iam.Service, logger *log.Logger, fileManagerSvc *filemanager.Service, baseURL *baseurl.BaseURL, cookieConfig securecookie.Config) http.Handler {
func NewGraphQLHandler(svc *iam.Service, logger *log.Logger, fileManagerSvc *filemanager.Service, baseURL *baseurl.BaseURL, cookieConfig securecookie.Config, limits gqlutils.Limits) http.Handler {
config := schema.Config{
Resolvers: &Resolver{
authorize: authz.NewAuthorizeFunc(svc, logger),
@@ -49,7 +49,7 @@ func NewGraphQLHandler(svc *iam.Service, logger *log.Logger, fileManagerSvc *fil
}
es := schema.NewExecutableSchema(config)
gqlh := gqlutils.NewHandler(es, logger)
gqlh := gqlutils.NewHandler(es, logger, limits)
return gqlh
}

View File

@@ -46,6 +46,7 @@ import (
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/authz"
"go.probo.inc/probo/pkg/server/api/connect/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
)
type (
@@ -70,6 +71,7 @@ func NewMux(
baseURL *baseurl.BaseURL,
allowedRedirectHost saferedirect.AllowedHostFunc,
isTrustCenterDomain IsTrustCenterDomainFunc,
graphqlLimits gqlutils.Limits,
) *chi.Mux {
r := chi.NewMux()
@@ -77,7 +79,7 @@ func NewMux(
apiKeyMiddleware := authn.NewAPIKeyMiddleware(svc, tokenSecret)
oauth2Middleware := authn.NewOAuth2AccessTokenMiddleware(svc)
identityPresenceMiddleware := authn.NewIdentityPresenceMiddleware(baseURL)
graphqlHandler := NewGraphQLHandler(svc, logger, fileManagerSvc, baseURL, cookieConfig)
graphqlHandler := NewGraphQLHandler(svc, logger, fileManagerSvc, baseURL, cookieConfig, graphqlLimits)
samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL, logger)
scimHandler := NewSCIMHandler(svc, logger.Named("scim"))

View File

@@ -55,6 +55,7 @@ func NewGraphQLHandler(
riskManagementSvc *riskmanagement.Service,
fileManagerSvc *filemanager.Service,
baseURL *baseurl.BaseURL,
limits gqlutils.Limits,
) http.Handler {
config := schema.Config{
Resolvers: &Resolver{
@@ -80,7 +81,7 @@ func NewGraphQLHandler(
}
es := schema.NewExecutableSchema(config)
gqlh := gqlutils.NewHandler(es, logger)
gqlh := gqlutils.NewHandler(es, logger, limits)
return gqlh
}

View File

@@ -47,6 +47,7 @@ import (
"go.probo.inc/probo/pkg/server/api/authz"
"go.probo.inc/probo/pkg/server/api/console/v1/dataloader"
"go.probo.inc/probo/pkg/server/api/console/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/thirdparty"
)
@@ -92,6 +93,7 @@ func NewMux(
customDomainCname string,
thirdPartySvc *thirdparty.Service,
riskManagementSvc *riskmanagement.Service,
graphqlLimits gqlutils.Limits,
) *chi.Mux {
r := chi.NewMux()
@@ -114,6 +116,7 @@ func NewMux(
riskManagementSvc,
fileManagerSvc,
baseURL,
graphqlLimits,
)
r.Group(func(r chi.Router) {

View File

@@ -44,6 +44,7 @@ func NewGraphQLHandler(
baseURL *baseurl.BaseURL,
cookieConfig securecookie.Config,
tokenSecret string,
limits gqlutils.Limits,
) http.Handler {
config := schema.Config{
Resolvers: &Resolver{
@@ -65,7 +66,7 @@ func NewGraphQLHandler(
}
es := schema.NewExecutableSchema(config)
gqlh := gqlutils.NewHandler(es, logger)
gqlh := gqlutils.NewHandler(es, logger, limits)
return gqlh
}

View File

@@ -46,6 +46,7 @@ import (
"go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/compliancepage"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/trust"
)
@@ -85,6 +86,7 @@ func NewMux(
cookieConfig securecookie.Config,
tokenSecret string,
baseURL *baseurl.BaseURL,
graphqlLimits gqlutils.Limits,
) *chi.Mux {
r := chi.NewMux()
@@ -112,6 +114,7 @@ func NewMux(
baseURL,
cookieConfig,
tokenSecret,
graphqlLimits,
)
r.Group(

View File

@@ -21,13 +21,27 @@ import (
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/lru"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/vektah/gqlparser/v2/ast"
"go.gearno.de/kit/log"
)
type Handler struct {
gqlhandler *handler.Server
}
type (
Handler struct {
gqlhandler *handler.Server
}
// Limits bounds the per-request cost of a GraphQL operation to protect
// against alias-flooding and other application-layer denial-of-service
// vectors. A zero value disables the corresponding guard.
Limits struct {
ParserTokenLimit int
ComplexityLimit int
QueryCacheSize int
DisableSuggestion bool
}
)
var (
mb int64 = 1024 * 1024
@@ -42,13 +56,27 @@ var (
introspectionExtension = extension.Introspection{}
)
func NewHandler[S graphql.ExecutableSchema](executableSchema S, logger *log.Logger) *Handler {
func NewHandler[S graphql.ExecutableSchema](executableSchema S, logger *log.Logger, limits Limits) *Handler {
handler := handler.New(executableSchema)
handler.AddTransport(postTransport)
handler.AddTransport(optionsTransport)
handler.AddTransport(multipartTransport)
if limits.QueryCacheSize > 0 {
handler.SetQueryCache(lru.New[*ast.QueryDocument](limits.QueryCacheSize))
}
if limits.ParserTokenLimit > 0 {
handler.SetParserTokenLimit(limits.ParserTokenLimit)
}
if limits.ComplexityLimit > 0 {
handler.Use(extension.FixedComplexityLimit(limits.ComplexityLimit))
}
handler.SetDisableSuggestion(limits.DisableSuggestion)
handler.Use(introspectionExtension)
handler.Use(NewTracingExtension(logger))

View File

@@ -42,6 +42,7 @@ import (
"go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/api"
"go.probo.inc/probo/pkg/server/api/compliancepage"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/server/mailactions"
trust_web "go.probo.inc/probo/pkg/server/trust"
console_web "go.probo.inc/probo/pkg/server/web"
@@ -74,6 +75,7 @@ type Config struct {
ConnectorRegistry *connector.ConnectorRegistry
ProviderRegistry *provider.Registry
CustomDomainCname string
GraphQLLimits gqlutils.Limits
Logger *log.Logger
}
@@ -114,6 +116,7 @@ func NewServer(cfg Config) (*Server, error) {
ConnectorRegistry: cfg.ConnectorRegistry,
ProviderRegistry: cfg.ProviderRegistry,
CustomDomainCname: cfg.CustomDomainCname,
GraphQLLimits: cfg.GraphQLLimits,
Logger: cfg.Logger.Named("api"),
}