diff --git a/contrib/helm/charts/probo/templates/deployment.yaml b/contrib/helm/charts/probo/templates/deployment.yaml index cfc310d8d..bd8c9f1bb 100644 --- a/contrib/helm/charts/probo/templates/deployment.yaml +++ b/contrib/helm/charts/probo/templates/deployment.yaml @@ -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 }} diff --git a/contrib/helm/charts/probo/values-production.yaml.example b/contrib/helm/charts/probo/values-production.yaml.example index ee5861ecf..251b0d5c4 100644 --- a/contrib/helm/charts/probo/values-production.yaml.example +++ b/contrib/helm/charts/probo/values-production.yaml.example @@ -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" diff --git a/contrib/helm/charts/probo/values.yaml b/contrib/helm/charts/probo/values.yaml index a52fe7998..4b73b9e77 100644 --- a/contrib/helm/charts/probo/values.yaml +++ b/contrib/helm/charts/probo/values.yaml @@ -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 diff --git a/go.mod b/go.mod index 7be1de050..4b3e5a686 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index 36846c056..37a904906 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -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"), diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go index 850938076..6f7f1454c 100644 --- a/pkg/bootstrap/builder_test.go +++ b/pkg/bootstrap/builder_test.go @@ -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) diff --git a/pkg/probod/aliases.go b/pkg/probod/aliases.go index e1028ffbe..4eed943fd 100644 --- a/pkg/probod/aliases.go +++ b/pkg/probod/aliases.go @@ -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 diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index b53844fc0..240f231ea 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -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, diff --git a/pkg/probodconfig/api_config.go b/pkg/probodconfig/api_config.go index 1974cca4e..eceeb9bd6 100644 --- a/pkg/probodconfig/api_config.go +++ b/pkg/probodconfig/api_config.go @@ -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"` } diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index 0fb4df5db..f06cb6e73 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -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 } diff --git a/pkg/server/api/connect/v1/graphql_handler.go b/pkg/server/api/connect/v1/graphql_handler.go index e27d43d8c..c1f10031b 100644 --- a/pkg/server/api/connect/v1/graphql_handler.go +++ b/pkg/server/api/connect/v1/graphql_handler.go @@ -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 } diff --git a/pkg/server/api/connect/v1/resolver.go b/pkg/server/api/connect/v1/resolver.go index 0d2443487..1c86cbac8 100644 --- a/pkg/server/api/connect/v1/resolver.go +++ b/pkg/server/api/connect/v1/resolver.go @@ -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")) diff --git a/pkg/server/api/console/v1/graphql_handler.go b/pkg/server/api/console/v1/graphql_handler.go index 1b293f55b..2f12d5919 100644 --- a/pkg/server/api/console/v1/graphql_handler.go +++ b/pkg/server/api/console/v1/graphql_handler.go @@ -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 } diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index 36726f679..0604cb1a9 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -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) { diff --git a/pkg/server/api/trust/v1/graphql_handler.go b/pkg/server/api/trust/v1/graphql_handler.go index d9336c726..f6822a321 100644 --- a/pkg/server/api/trust/v1/graphql_handler.go +++ b/pkg/server/api/trust/v1/graphql_handler.go @@ -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 } diff --git a/pkg/server/api/trust/v1/resolver.go b/pkg/server/api/trust/v1/resolver.go index 1f2980984..0bedcb05c 100644 --- a/pkg/server/api/trust/v1/resolver.go +++ b/pkg/server/api/trust/v1/resolver.go @@ -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( diff --git a/pkg/server/gqlutils/handler.go b/pkg/server/gqlutils/handler.go index 647115491..2fcf6261b 100644 --- a/pkg/server/gqlutils/handler.go +++ b/pkg/server/gqlutils/handler.go @@ -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)) diff --git a/pkg/server/server.go b/pkg/server/server.go index f879360f8..87143dad8 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -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"), }