From 6b174de19a419d237d12d55aebbc6a72a26b782c Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Thu, 22 Jan 2026 12:58:12 +0200 Subject: [PATCH 01/11] add config options --- router/core/executor.go | 5 +++ router/core/factoryresolver.go | 20 ++++----- router/core/graph_server.go | 17 ++++---- router/core/graphql_prehandler.go | 19 +++++---- router/core/operation_planner.go | 6 +-- router/core/operation_processor.go | 46 +++++++++++++++++++-- router/core/websocket.go | 15 ++++--- router/pkg/config/config.go | 17 ++++++++ router/pkg/config/config.schema.json | 24 +++++++++++ router/pkg/config/fixtures/full.yaml | 4 ++ router/pkg/config/testdata/config_full.json | 5 +++ 11 files changed, 140 insertions(+), 38 deletions(-) diff --git a/router/core/executor.go b/router/core/executor.go index e29ed7682b..3c1f91e74e 100644 --- a/router/core/executor.go +++ b/router/core/executor.go @@ -243,5 +243,10 @@ func (b *ExecutorConfigurationBuilder) buildPlannerConfiguration(ctx context.Con planConfig.BuildFetchReasons = routerEngineCfg.Execution.EnableRequireFetchReasons || routerEngineCfg.Execution.ValidateRequiredExternalFields planConfig.ValidateRequiredExternalFields = routerEngineCfg.Execution.ValidateRequiredExternalFields + // Enable static cost computation when cost analysis is enabled + if routerEngineCfg.CostAnalysis != nil && routerEngineCfg.CostAnalysis.Enabled { + planConfig.ComputeStaticCost = true + } + return planConfig, providers, nil } diff --git a/router/core/factoryresolver.go b/router/core/factoryresolver.go index d8cfecc283..80a030bfcc 100644 --- a/router/core/factoryresolver.go +++ b/router/core/factoryresolver.go @@ -8,26 +8,23 @@ import ( "net/url" "slices" - rmetric "github.com/wundergraph/cosmo/router/pkg/metric" - "github.com/buger/jsonparser" + "github.com/jensneuse/abstractlogger" + "go.uber.org/zap" + + "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/common" + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/grpcconnector" + rmetric "github.com/wundergraph/cosmo/router/pkg/metric" "github.com/wundergraph/cosmo/router/pkg/pubsub" pubsub_datasource "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/argument_templates" - - "github.com/wundergraph/cosmo/router/pkg/config" - - "github.com/jensneuse/abstractlogger" - "go.uber.org/zap" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/argument_templates" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/graphql_datasource" grpcdatasource "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/grpc_datasource" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/staticdatasource" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" - - "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/common" - nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" ) type Loader struct { @@ -216,6 +213,7 @@ type RouterEngineConfiguration struct { Events config.EventsConfiguration SubgraphErrorPropagation config.SubgraphErrorPropagationConfiguration StreamMetricStore rmetric.StreamMetricStore + CostAnalysis *config.CostAnalysis } func mapProtoFilterToPlanFilter(input *nodev1.SubscriptionFilterCondition, output *plan.SubscriptionFilterCondition) *plan.SubscriptionFilterCondition { diff --git a/router/core/graph_server.go b/router/core/graph_server.go index 87aa96331c..02585b7f6d 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -22,7 +22,6 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/klauspost/compress/gzhttp" "github.com/klauspost/compress/gzip" - "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "go.opentelemetry.io/otel/attribute" otelmetric "go.opentelemetry.io/otel/metric" oteltrace "go.opentelemetry.io/otel/trace" @@ -57,6 +56,8 @@ import ( "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" "github.com/wundergraph/cosmo/router/pkg/statistics" rtrace "github.com/wundergraph/cosmo/router/pkg/trace" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" ) const ( @@ -519,12 +520,12 @@ type graphMux struct { validationCache *ristretto.Cache[uint64, bool] operationHashCache *ristretto.Cache[uint64, string] - accessLogsFileLogger *logging.BufferedLogger - metricStore rmetric.Store - prometheusCacheMetrics *rmetric.CacheMetrics - otelCacheMetrics *rmetric.CacheMetrics - streamMetricStore rmetric.StreamMetricStore - prometheusMetricsExporter *graphqlmetrics.PrometheusMetricsExporter + accessLogsFileLogger *logging.BufferedLogger + metricStore rmetric.Store + prometheusCacheMetrics *rmetric.CacheMetrics + otelCacheMetrics *rmetric.CacheMetrics + streamMetricStore rmetric.StreamMetricStore + prometheusMetricsExporter *graphqlmetrics.PrometheusMetricsExporter } // buildOperationCaches creates the caches for the graph mux. @@ -1192,6 +1193,7 @@ func (s *graphServer) buildGraphMux( Events: s.eventsConfig, SubgraphErrorPropagation: s.subgraphErrorPropagation, StreamMetricStore: gm.streamMetricStore, + CostAnalysis: s.securityConfiguration.CostAnalysis, } // map[string]*http.Transport cannot be coerced into map[string]http.RoundTripper, unfortunately @@ -1295,6 +1297,7 @@ func (s *graphServer) buildGraphMux( ApolloRouterCompatibilityFlags: s.apolloRouterCompatibilityFlags, DisableExposingVariablesContentOnValidationError: s.engineExecutionConfiguration.DisableExposingVariablesContentOnValidationError, ComplexityLimits: s.securityConfiguration.ComplexityLimits, + CostAnalysis: s.securityConfiguration.CostAnalysis, }) operationPlanner := NewOperationPlanner(executor, gm.planCache) diff --git a/router/core/graphql_prehandler.go b/router/core/graphql_prehandler.go index 02a96f69e4..d818b6e193 100644 --- a/router/core/graphql_prehandler.go +++ b/router/core/graphql_prehandler.go @@ -14,29 +14,27 @@ import ( "sync" "time" - "go.opentelemetry.io/otel/codes" - "github.com/go-chi/chi/v5/middleware" "github.com/golang-jwt/jwt/v5" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" otelmetric "go.opentelemetry.io/otel/metric" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" "github.com/wundergraph/astjson" - - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" - "github.com/wundergraph/graphql-go-tools/v2/pkg/graphqlerrors" - "github.com/wundergraph/cosmo/router/internal/expr" "github.com/wundergraph/cosmo/router/internal/persistedoperation" "github.com/wundergraph/cosmo/router/pkg/art" "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/otel" rtrace "github.com/wundergraph/cosmo/router/pkg/trace" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" + "github.com/wundergraph/graphql-go-tools/v2/pkg/graphqlerrors" ) type PreHandlerOptions struct { @@ -1070,6 +1068,11 @@ func (h *PreHandler) handleOperation(w http.ResponseWriter, req *http.Request, v enginePlanSpan.SetAttributes(otel.WgEnginePlanCacheHit.Bool(requestContext.operation.planCacheHit)) enginePlanSpan.End() + // Check static cost limits after planning + if err := operationKit.ValidateStaticCost(requestContext.operation.preparedPlan.preparedPlan, requestContext.operation.variables); err != nil { + return err + } + planningAttrs := *requestContext.telemetry.AcquireAttributes() planningAttrs = append(planningAttrs, otel.WgEnginePlanCacheHit.Bool(requestContext.operation.planCacheHit)) planningAttrs = append(planningAttrs, requestContext.telemetry.metricAttrs...) diff --git a/router/core/operation_planner.go b/router/core/operation_planner.go index 38c3b6aac5..e6032fd52a 100644 --- a/router/core/operation_planner.go +++ b/router/core/operation_planner.go @@ -6,14 +6,14 @@ import ( "golang.org/x/sync/singleflight" + graphqlmetricsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1" + "github.com/wundergraph/cosmo/router/pkg/graphqlschemausage" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/postprocess" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" - - graphqlmetricsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1" - "github.com/wundergraph/cosmo/router/pkg/graphqlschemausage" ) type planWithMetaData struct { diff --git a/router/core/operation_processor.go b/router/core/operation_processor.go index 8ec9ba5bd1..27bf8b2c2e 100644 --- a/router/core/operation_processor.go +++ b/router/core/operation_processor.go @@ -21,7 +21,11 @@ import ( "github.com/dgraph-io/ristretto/v2" "github.com/pkg/errors" "github.com/tidwall/sjson" + fastjson "github.com/wundergraph/astjson" + "github.com/wundergraph/cosmo/router/internal/persistedoperation" + "github.com/wundergraph/cosmo/router/internal/unsafebytes" + "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/graphql-go-tools/v2/pkg/apollocompatibility" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" @@ -30,13 +34,10 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/astprinter" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvalidation" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "github.com/wundergraph/graphql-go-tools/v2/pkg/middleware/operation_complexity" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" "github.com/wundergraph/graphql-go-tools/v2/pkg/variablesvalidation" - - "github.com/wundergraph/cosmo/router/internal/persistedoperation" - "github.com/wundergraph/cosmo/router/internal/unsafebytes" - "github.com/wundergraph/cosmo/router/pkg/config" ) var ( @@ -123,6 +124,7 @@ type OperationProcessorOptions struct { ApolloRouterCompatibilityFlags config.ApolloRouterCompatibilityFlags DisableExposingVariablesContentOnValidationError bool ComplexityLimits *config.ComplexityLimits + CostAnalysis *config.CostAnalysis ParserTokenizerLimits astparser.TokenizerLimits OperationNameLengthLimit int } @@ -139,6 +141,7 @@ type OperationProcessor struct { introspectionEnabled bool parseKitOptions *parseKitOptions complexityLimits *config.ComplexityLimits + costAnalysis *config.CostAnalysis parserTokenizerLimits astparser.TokenizerLimits operationNameLengthLimit int } @@ -1361,6 +1364,40 @@ func (o *OperationKit) runComplexityComparisons(complexityLimitConfig *config.Co return nil } +// ValidateStaticCost validates that the estimated query cost is within the configured limit. +// This should be called after planning, as the cost calculator is populated during the planning phase. +func (o *OperationKit) ValidateStaticCost(preparedPlan plan.Plan, variables *fastjson.Value) error { + costAnalysis := o.operationProcessor.costAnalysis + if preparedPlan == nil || costAnalysis == nil || !costAnalysis.Enabled { + return nil + } + + if costAnalysis.StaticLimit <= 0 { + return nil + } + + costCalc := preparedPlan.GetStaticCostCalculator() + if costCalc == nil { + return nil + } + + if costAnalysis.ListSize > 0 { + plan.StaticCostDefaults.List = costAnalysis.ListSize + } + costCalc.SetVariables(variables) + + estimatedCost := costCalc.GetStaticCost() + + if estimatedCost > costAnalysis.StaticLimit { + return &httpGraphqlError{ + message: fmt.Sprintf("The estimated query cost %d exceeds the maximum allowed cost (%d)", estimatedCost, costAnalysis.StaticLimit), + statusCode: http.StatusBadRequest, + } + } + + return nil +} + var ( literalIF = []byte("if") ) @@ -1449,6 +1486,7 @@ func NewOperationProcessor(opts OperationProcessorOptions) *OperationProcessor { parserTokenizerLimits: opts.ParserTokenizerLimits, operationNameLengthLimit: opts.OperationNameLengthLimit, complexityLimits: opts.ComplexityLimits, + costAnalysis: opts.CostAnalysis, parseKitOptions: &parseKitOptions{ apolloCompatibilityFlags: opts.ApolloCompatibilityFlags, apolloRouterCompatibilityFlags: opts.ApolloRouterCompatibilityFlags, diff --git a/router/core/websocket.go b/router/core/websocket.go index 35d6ffc304..0df395bb8f 100644 --- a/router/core/websocket.go +++ b/router/core/websocket.go @@ -20,14 +20,10 @@ import ( "github.com/gobwas/ws/wsutil" "github.com/gorilla/websocket" "github.com/tidwall/gjson" - "github.com/wundergraph/astjson" "go.uber.org/atomic" "go.uber.org/zap" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" - "github.com/wundergraph/graphql-go-tools/v2/pkg/netpoll" - + "github.com/wundergraph/astjson" "github.com/wundergraph/cosmo/router/internal/expr" "github.com/wundergraph/cosmo/router/internal/persistedoperation" "github.com/wundergraph/cosmo/router/internal/wsproto" @@ -36,6 +32,10 @@ import ( "github.com/wundergraph/cosmo/router/pkg/logging" "github.com/wundergraph/cosmo/router/pkg/statistics" rtrace "github.com/wundergraph/cosmo/router/pkg/trace" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" + "github.com/wundergraph/graphql-go-tools/v2/pkg/netpoll" ) var ( @@ -957,6 +957,11 @@ func (h *WebSocketConnectionHandler) parseAndPlan(registration *SubscriptionRegi opContext.planningTime = time.Since(startPlanning) + // Check static cost limits after planning + if err := operationKit.ValidateStaticCost(opContext.preparedPlan.preparedPlan, opContext.variables); err != nil { + return operationKit.parsedOperation, nil, err + } + opContext.initialPayload = h.initialPayload return operationKit.parsedOperation, opContext, nil diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 8eb71bc5f1..75256fdf2a 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -426,6 +426,7 @@ type SecurityConfiguration struct { BlockPersistedOperations BlockOperationConfiguration `yaml:"block_persisted_operations" envPrefix:"SECURITY_BLOCK_PERSISTED_OPERATIONS_"` ComplexityCalculationCache *ComplexityCalculationCache `yaml:"complexity_calculation_cache"` ComplexityLimits *ComplexityLimits `yaml:"complexity_limits"` + CostAnalysis *CostAnalysis `yaml:"cost_analysis"` DepthLimit *QueryDepthConfiguration `yaml:"depth_limit"` ParserLimits ParserLimitsConfiguration `yaml:"parser_limits"` OperationNameLengthLimit int `yaml:"operation_name_length_limit" envDefault:"512" env:"SECURITY_OPERATION_NAME_LENGTH_LIMIT"` // 0 is disabled @@ -458,6 +459,22 @@ type ComplexityLimits struct { IgnoreIntrospection bool `yaml:"ignore_introspection" envDefault:"false" env:"SECURITY_COMPLEXITY_IGNORE_INTROSPECTION"` } +// CostAnalysis configures cost analysis based on @cost and @listSize directives. +type CostAnalysis struct { + // When enabled, static cost is calculated and exposed to modules. + // Set to true to enable StaticLimit option. + Enabled bool `yaml:"enabled" envDefault:"false" env:"SECURITY_COST_ANALYSIS_ENABLED"` + + // StaticLimit is the maximum allowed static (estimated) cost for a query. + // Queries exceeding this limit will be rejected before execution. + // If the limit is 0, this limit isn't applied. + StaticLimit int `yaml:"static_limit,omitempty" envDefault:"0" env:"SECURITY_COST_ANALYSIS_STATIC_LIMIT"` + + // ListSize is the default assumed size for list fields when no @listSize directive + // nor slicing argument is provided. Used as a multiplier for list field static costs. + ListSize int `yaml:"list_size,omitempty" envDefault:"10" env:"SECURITY_COST_ANALYSIS_LIST_SIZE"` +} + type ComplexityLimit struct { Enabled bool `yaml:"enabled" envDefault:"false"` Limit int `yaml:"limit,omitempty" envDefault:"0"` diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index a531fa4af3..64556b1ca5 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2683,6 +2683,30 @@ } } }, + "cost_analysis": { + "type": "object", + "description": "Cost analysis based on @cost and @listSize directives.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "When enabled, static cost is calculated and exposed to modules. Set to true to enable StaticLimit option." + }, + "static_limit": { + "type": "integer", + "description": "The maximum allowed static (estimated) cost for a query. Queries exceeding this limit will be rejected before execution. If the limit is 0, this limit isn't applied.", + "default": 0, + "minimum": 0 + }, + "list_size": { + "type": "integer", + "description": "The default assumed size for list fields when no @listSize directive nor slicing argument is provided. Used as a multiplier for list field static costs.", + "default": 10, + "minimum": 1 + } + } + }, "operation_name_length_limit": { "type": "integer", "description": "The maximum allowed length of the operation name, 0 allows any length.", diff --git a/router/pkg/config/fixtures/full.yaml b/router/pkg/config/fixtures/full.yaml index 13628a925a..3b6659f394 100644 --- a/router/pkg/config/fixtures/full.yaml +++ b/router/pkg/config/fixtures/full.yaml @@ -446,6 +446,10 @@ security: enabled: true limit: 4 ignore_persisted_operations: true + cost_analysis: + enabled: true + static_limit: 1000 + list_size: 10 operation_name_length_limit: 2000 persisted_operations: safelist: diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index d4707aa1a8..31b6957597 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -728,6 +728,11 @@ }, "IgnoreIntrospection": false }, + "CostAnalysis": { + "Enabled": true, + "StaticLimit": 1000, + "ListSize": 10 + }, "DepthLimit": null, "ParserLimits": { "ApproximateDepthLimit": 200, From 25900b970925b2f05388c5e8ece59e20fd105f1e Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:37:07 +0200 Subject: [PATCH 02/11] test how modules are support --- .../modules/verify-static-cost/module.go | 63 ++++++++ .../modules/verify_static_cost_test.go | 103 +++++++++++++ router-tests/security_test.go | 141 ++++++++++++++++++ router/core/context.go | 19 +++ router/core/executor.go | 11 +- router/core/factoryresolver.go | 2 + router/core/operation_processor.go | 5 +- 7 files changed, 335 insertions(+), 9 deletions(-) create mode 100644 router-tests/modules/verify-static-cost/module.go create mode 100644 router-tests/modules/verify_static_cost_test.go diff --git a/router-tests/modules/verify-static-cost/module.go b/router-tests/modules/verify-static-cost/module.go new file mode 100644 index 0000000000..aee17f7efd --- /dev/null +++ b/router-tests/modules/verify-static-cost/module.go @@ -0,0 +1,63 @@ +package verify_static_cost + +import ( + "net/http" + + "go.uber.org/zap" + + "github.com/wundergraph/cosmo/router/core" +) + +const myModuleID = "verifyStaticCost" + +// CapturedStaticCost holds the captured static cost from operation context +type CapturedStaticCost struct { + Cost int + Error error +} + +// VerifyStaticCostModule captures static cost for verification in tests +type VerifyStaticCostModule struct { + ResultsChan chan CapturedStaticCost + Logger *zap.Logger +} + +func (m *VerifyStaticCostModule) Provision(ctx *core.ModuleContext) error { + m.Logger = ctx.Logger + if m.ResultsChan == nil { + m.ResultsChan = make(chan CapturedStaticCost, 1) + } + return nil +} + +func (m *VerifyStaticCostModule) Middleware(ctx core.RequestContext, next http.Handler) { + operation := ctx.Operation() + + cost, err := operation.StaticCost() + captured := CapturedStaticCost{Cost: cost, Error: err} + + // Send the captured values to the test + select { + case m.ResultsChan <- captured: + default: + // Channel is full, skip + } + + next.ServeHTTP(ctx.ResponseWriter(), ctx.Request()) +} + +func (m *VerifyStaticCostModule) Module() core.ModuleInfo { + return core.ModuleInfo{ + ID: myModuleID, + Priority: 1, + New: func() core.Module { + return &VerifyStaticCostModule{ + ResultsChan: make(chan CapturedStaticCost, 1), + } + }, + } +} + +var ( + _ core.RouterMiddlewareHandler = (*VerifyStaticCostModule)(nil) +) diff --git a/router-tests/modules/verify_static_cost_test.go b/router-tests/modules/verify_static_cost_test.go new file mode 100644 index 0000000000..44c6a1c08a --- /dev/null +++ b/router-tests/modules/verify_static_cost_test.go @@ -0,0 +1,103 @@ +package module_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + verifyModule "github.com/wundergraph/cosmo/router-tests/modules/verify-static-cost" + "github.com/wundergraph/cosmo/router-tests/testenv" + "github.com/wundergraph/cosmo/router/core" + "github.com/wundergraph/cosmo/router/pkg/config" +) + +func TestStaticCostModuleExposition(t *testing.T) { + t.Parallel() + + t.Run("module can access static cost when cost analysis is enabled", func(t *testing.T) { + t.Parallel() + + resultsChan := make(chan verifyModule.CapturedStaticCost, 1) + + cfg := config.Config{ + Graph: config.Graph{}, + Modules: map[string]any{ + "verifyStaticCost": verifyModule.VerifyStaticCostModule{ + ResultsChan: resultsChan, + }, + }, + } + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithModulesConfig(cfg.Modules), + core.WithCustomModules(&verifyModule.VerifyStaticCostModule{}), + }, + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + StaticLimit: 100, + ListSize: 10, + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res, err := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{ + Query: `query GetEmployee { employee(id: 1) { id } }`, + }) + require.NoError(t, err) + assert.Equal(t, 200, res.Response.StatusCode) + + testenv.AwaitChannelWithT(t, 10*time.Second, resultsChan, func(t *testing.T, captured verifyModule.CapturedStaticCost) { + assert.NoError(t, captured.Error, "StaticCost() should not return an error when cost analysis is enabled") + assert.Greater(t, captured.Cost, 0, "Static cost should be greater than 0 for a query with object fields") + + // Log the cost for demonstration purposes + t.Logf("Query static cost: %d (could be used for rate limiting)", captured.Cost) + // In a real module, you could use this cost for: + // - Rate limiting (deduct from user's cost budget) + // - Logging/metrics + // - Custom rejection logic + // - Billing/monetization + + }) + }) + }) + + t.Run("module receives error when cost analysis is disabled", func(t *testing.T) { + t.Parallel() + + resultsChan := make(chan verifyModule.CapturedStaticCost, 1) + + cfg := config.Config{ + Graph: config.Graph{}, + Modules: map[string]any{ + "verifyStaticCost": verifyModule.VerifyStaticCostModule{ + ResultsChan: resultsChan, + }, + }, + } + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithModulesConfig(cfg.Modules), + core.WithCustomModules(&verifyModule.VerifyStaticCostModule{}), + }, + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = nil + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res, err := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{ + Query: `query GetEmployee { employee(id: 1) { id } }`, + }) + require.NoError(t, err) + assert.Equal(t, 200, res.Response.StatusCode) + + testenv.AwaitChannelWithT(t, 10*time.Second, resultsChan, func(t *testing.T, captured verifyModule.CapturedStaticCost) { + assert.Error(t, captured.Error, "StaticCost() should return an error when cost analysis is disabled") + assert.Equal(t, 0, captured.Cost, "Cost should be 0 when cost analysis is disabled") + }) + }) + }) +} diff --git a/router-tests/security_test.go b/router-tests/security_test.go index 74fba8fd7c..792c015c85 100644 --- a/router-tests/security_test.go +++ b/router-tests/security_test.go @@ -364,4 +364,145 @@ func TestQueryNamingLimits(t *testing.T) { }) }) }) + + t.Run("cost analysis", func(t *testing.T) { + t.Parallel() + + // These tests verify that static cost is calculated using default values + // when no @cost or @listSize directives are specified in the schema. + + t.Run("blocks queries exceeding static cost limit with default values", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + StaticLimit: 9, + ListSize: 5, + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res, _ := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{ + Query: `{ employees { id details { forename surname } } }`, + }) + // cost = 5 * (1 + 1) + require.Equal(t, 400, res.Response.StatusCode) + require.Contains(t, res.Body, "exceeds the maximum allowed static cost") + }) + }) + + t.Run("allows queries under static cost limit with default values", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + StaticLimit: 11, + ListSize: 5, + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employees { id details { forename surname } } }`, + }) + // cost = 5 * (1 + 1) + require.Equal(t, 200, res.Response.StatusCode) + require.Contains(t, res.Body, `"data":`) + }) + }) + + t.Run("non-list query with defaults", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + StaticLimit: 2, // employee (1) + details (1) = 2 + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employee(id:1) { id details { forename } } }`, + }) + // Cost: 1 + 1 + require.Equal(t, 200, res.Response.StatusCode) + require.Contains(t, res.Body, `"data":`) + }) + }) + + t.Run("list size configuration affects cost calculation", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + StaticLimit: 2, + ListSize: 2, // Small list size + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employees { id } }`, + }) + // cost: 2 * 1 + require.Contains(t, res.Body, `"data":`) + }) + }) + + t.Run("disabled cost analysis does not block queries", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: false, + StaticLimit: 1, + ListSize: 10, + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employees { id details { forename surname } } }`, + }) + // cost = 10 * (1 + 1) + require.Contains(t, res.Body, `"data":`) + }) + }) + + t.Run("zero limit does not block queries", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + StaticLimit: 0, // Zero means no limit applied, but cost is calculated. + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employees { id details { forename surname } } }`, + }) + require.Contains(t, res.Body, `"data":`) + }) + }) + + t.Run("nested list fields multiply cost", func(t *testing.T) { + // Just one additional test; more thorough testing is done in the engine. + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + StaticLimit: 24, + ListSize: 5, + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employees { id details { forename } } }`, + }) + // cost = 5 * (5 * 1) + require.Contains(t, res.Body, `"data":`) + }) + }) + }) } diff --git a/router/core/context.go b/router/core/context.go index 264e166c32..f4b3883f67 100644 --- a/router/core/context.go +++ b/router/core/context.go @@ -504,6 +504,11 @@ type OperationContext interface { // if called too early in request chain, it may be inaccurate for modules, using // in Middleware is recommended QueryPlanStats() (QueryPlanStats, error) + + // StaticCost returns the static (estimated) cost of the operation based on @cost and @listSize directives. + // Returns 0 if cost analysis is not enabled or the plan is not yet available. + // This should be called after planning is complete, using in Middleware is recommended. + StaticCost() (int, error) } var _ OperationContext = (*operationContext)(nil) @@ -721,6 +726,20 @@ func (o *operationContext) QueryPlanStats() (QueryPlanStats, error) { return qps, nil } +func (o *operationContext) StaticCost() (int, error) { + if o == nil || o.preparedPlan == nil || o.preparedPlan.preparedPlan == nil { + return 0, errors.New("operation context or prepared plan is nil") + } + + costCalc := o.preparedPlan.preparedPlan.GetStaticCostCalculator() + if costCalc == nil { + return 0, errors.New("cost analysis is not enabled") + } + + costCalc.SetVariables(o.variables) + return costCalc.GetStaticCost(), nil +} + type SubgraphResolver struct { subgraphsByURL map[string]*Subgraph subgraphsByID map[string]*Subgraph diff --git a/router/core/executor.go b/router/core/executor.go index 3c1f91e74e..ae106ee204 100644 --- a/router/core/executor.go +++ b/router/core/executor.go @@ -8,6 +8,11 @@ import ( "go.uber.org/zap" + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/cosmo/router/pkg/grpcconnector" + pubsub_datasource "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" @@ -15,11 +20,6 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" - - nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" - "github.com/wundergraph/cosmo/router/pkg/config" - "github.com/wundergraph/cosmo/router/pkg/grpcconnector" - pubsub_datasource "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" ) type ExecutorConfigurationBuilder struct { @@ -246,6 +246,7 @@ func (b *ExecutorConfigurationBuilder) buildPlannerConfiguration(ctx context.Con // Enable static cost computation when cost analysis is enabled if routerEngineCfg.CostAnalysis != nil && routerEngineCfg.CostAnalysis.Enabled { planConfig.ComputeStaticCost = true + planConfig.StaticCostDefaultListSize = routerEngineCfg.CostAnalysis.ListSize } return planConfig, providers, nil diff --git a/router/core/factoryresolver.go b/router/core/factoryresolver.go index 80a030bfcc..ae6f4b0767 100644 --- a/router/core/factoryresolver.go +++ b/router/core/factoryresolver.go @@ -553,6 +553,7 @@ func (l *Loader) dataSourceMetaData(in *nodev1.DataSourceConfiguration) *plan.Da EntityInterfaces: make([]plan.EntityInterfaceConfiguration, 0, len(in.EntityInterfaces)), InterfaceObjects: make([]plan.EntityInterfaceConfiguration, 0, len(in.InterfaceObjects)), }, + CostConfig: plan.NewDataSourceCostConfig(), } for _, node := range in.RootNodes { @@ -633,6 +634,7 @@ func (l *Loader) dataSourceMetaData(in *nodev1.DataSourceConfiguration) *plan.Da ConcreteTypeNames: interfaceObjectConfiguration.ConcreteTypeNames, }) } + // TODO: import CostConfigs from in (produced by composition) to be consumed by the router. return out } diff --git a/router/core/operation_processor.go b/router/core/operation_processor.go index 27bf8b2c2e..84864c13de 100644 --- a/router/core/operation_processor.go +++ b/router/core/operation_processor.go @@ -1381,16 +1381,13 @@ func (o *OperationKit) ValidateStaticCost(preparedPlan plan.Plan, variables *fas return nil } - if costAnalysis.ListSize > 0 { - plan.StaticCostDefaults.List = costAnalysis.ListSize - } costCalc.SetVariables(variables) estimatedCost := costCalc.GetStaticCost() if estimatedCost > costAnalysis.StaticLimit { return &httpGraphqlError{ - message: fmt.Sprintf("The estimated query cost %d exceeds the maximum allowed cost (%d)", estimatedCost, costAnalysis.StaticLimit), + message: fmt.Sprintf("The estimated query cost %d exceeds the maximum allowed static cost (%d)", estimatedCost, costAnalysis.StaticLimit), statusCode: http.StatusBadRequest, } } From c899d244e16645b55c7ab4f3f64b1e3ef5b93346 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:15:16 +0200 Subject: [PATCH 03/11] test flooring of ListSize --- router-tests/security_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/router-tests/security_test.go b/router-tests/security_test.go index 792c015c85..b01f4fdce1 100644 --- a/router-tests/security_test.go +++ b/router-tests/security_test.go @@ -449,6 +449,26 @@ func TestQueryNamingLimits(t *testing.T) { }) }) + t.Run("blocks queries exceeding static cost limit when list size is zero", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + StaticLimit: 1, + ListSize: 0, // will be floored to 1 + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res, _ := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{ + Query: `{ employees { id details { forename surname } } }`, + }) + // cost = 1 * (1 + 1) + require.Equal(t, 400, res.Response.StatusCode) + require.Contains(t, res.Body, "exceeds the maximum allowed static cost") + }) + }) + t.Run("disabled cost analysis does not block queries", func(t *testing.T) { t.Parallel() testenv.Run(t, &testenv.Config{ From 85897a0fba3e8918de88362b9cfae4a08950a653 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:44:12 +0200 Subject: [PATCH 04/11] update with race fix in the engine --- router/core/context.go | 16 +++++++--------- router/core/operation_planner.go | 3 +++ router/core/operation_processor.go | 4 +--- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/router/core/context.go b/router/core/context.go index f4b3883f67..9db8f0ea20 100644 --- a/router/core/context.go +++ b/router/core/context.go @@ -11,23 +11,21 @@ import ( "sync" "time" - rcontext "github.com/wundergraph/cosmo/router/internal/context" - "go.opentelemetry.io/otel/attribute" "go.uber.org/zap" "github.com/wundergraph/astjson" - - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" - graphqlmetrics "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1" + rcontext "github.com/wundergraph/cosmo/router/internal/context" "github.com/wundergraph/cosmo/router/internal/expr" "github.com/wundergraph/cosmo/router/pkg/authentication" "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/graphqlschemausage" ctrace "github.com/wundergraph/cosmo/router/pkg/trace" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" ) var _ RequestContext = (*requestContext)(nil) @@ -541,6 +539,7 @@ type operationContext struct { variables *astjson.Value files []*httpclient.FileUpload clientInfo *ClientInfo + planConfig plan.Configuration // preparedPlan is the prepared plan of the operation preparedPlan *planWithMetaData traceOptions resolve.TraceOptions @@ -736,8 +735,7 @@ func (o *operationContext) StaticCost() (int, error) { return 0, errors.New("cost analysis is not enabled") } - costCalc.SetVariables(o.variables) - return costCalc.GetStaticCost(), nil + return costCalc.GetStaticCost(o.planConfig, o.variables), nil } type SubgraphResolver struct { diff --git a/router/core/operation_planner.go b/router/core/operation_planner.go index e6032fd52a..27d3b49c6e 100644 --- a/router/core/operation_planner.go +++ b/router/core/operation_planner.go @@ -53,6 +53,9 @@ func (p *OperationPlanner) preparePlan(ctx *operationContext) (*planWithMetaData return nil, &reportError{report: &report} } + // Store plan config to access it from the operationContext.ComputeStaticCost() + ctx.planConfig = p.executor.PlanConfig + planner, err := plan.NewPlanner(p.executor.PlanConfig) if err != nil { return nil, err diff --git a/router/core/operation_processor.go b/router/core/operation_processor.go index 84864c13de..6e7ba078cc 100644 --- a/router/core/operation_processor.go +++ b/router/core/operation_processor.go @@ -1381,9 +1381,7 @@ func (o *OperationKit) ValidateStaticCost(preparedPlan plan.Plan, variables *fas return nil } - costCalc.SetVariables(variables) - - estimatedCost := costCalc.GetStaticCost() + estimatedCost := costCalc.GetStaticCost(o.operationProcessor.executor.PlanConfig, variables) if estimatedCost > costAnalysis.StaticLimit { return &httpGraphqlError{ From cbac7436232b689a181f996f1fcd1b11a7b03a59 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:17:19 +0200 Subject: [PATCH 05/11] bump the engine --- router-tests/go.mod | 2 +- router-tests/go.sum | 4 ++-- router/go.mod | 2 +- router/go.sum | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/router-tests/go.mod b/router-tests/go.mod index 3de9ec5fcf..dbf9fbf1b2 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -26,7 +26,7 @@ require ( github.com/wundergraph/cosmo/demo/pkg/subgraphs/projects v0.0.0-20250715110703-10f2e5f9c79e github.com/wundergraph/cosmo/router v0.0.0-20251125205644-175f80c4e6d9 github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e - github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245 + github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245.0.20260126144918-2db07ae63d8d go.opentelemetry.io/otel v1.36.0 go.opentelemetry.io/otel/sdk v1.36.0 go.opentelemetry.io/otel/sdk/metric v1.36.0 diff --git a/router-tests/go.sum b/router-tests/go.sum index dc73f635fd..c3c99fb4cf 100644 --- a/router-tests/go.sum +++ b/router-tests/go.sum @@ -352,8 +352,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083 h1:8/D7f8gKxTBjW+SZK4mhxTTBVpxcqeBgWF1Rfmltbfk= github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083/go.mod h1:eOTL6acwctsN4F3b7YE+eE2t8zcJ/doLm9sZzsxxxrE= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245 h1:MYewlXgIhI9jusocPUeyo346J3M5cqzc6ddru1qp+S8= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245/go.mod h1:mX25ASEQiKamxaFSK6NZihh0oDCigIuzro30up4mFH4= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245.0.20260126144918-2db07ae63d8d h1:zd91uAVQyELsdTw2K1vUPh/wkXgIrc3a329r2bvbw5w= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245.0.20260126144918-2db07ae63d8d/go.mod h1:mX25ASEQiKamxaFSK6NZihh0oDCigIuzro30up4mFH4= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= diff --git a/router/go.mod b/router/go.mod index 415918d050..34a6e577ab 100644 --- a/router/go.mod +++ b/router/go.mod @@ -31,7 +31,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/twmb/franz-go v1.16.1 - github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245 + github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245.0.20260126144918-2db07ae63d8d // Do not upgrade, it renames attributes we rely on go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 go.opentelemetry.io/contrib/propagators/b3 v1.23.0 diff --git a/router/go.sum b/router/go.sum index 715b552f47..fbaaf52aff 100644 --- a/router/go.sum +++ b/router/go.sum @@ -322,8 +322,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083 h1:8/D7f8gKxTBjW+SZK4mhxTTBVpxcqeBgWF1Rfmltbfk= github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083/go.mod h1:eOTL6acwctsN4F3b7YE+eE2t8zcJ/doLm9sZzsxxxrE= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245 h1:MYewlXgIhI9jusocPUeyo346J3M5cqzc6ddru1qp+S8= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245/go.mod h1:mX25ASEQiKamxaFSK6NZihh0oDCigIuzro30up4mFH4= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245.0.20260126144918-2db07ae63d8d h1:zd91uAVQyELsdTw2K1vUPh/wkXgIrc3a329r2bvbw5w= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245.0.20260126144918-2db07ae63d8d/go.mod h1:mX25ASEQiKamxaFSK6NZihh0oDCigIuzro30up4mFH4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= From 551b21d04c8eea6ea44b2981d235782ac8312c42 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:50:13 +0200 Subject: [PATCH 06/11] use released engine --- router-tests/go.mod | 2 +- router-tests/go.sum | 4 ++-- router/go.mod | 2 +- router/go.sum | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/router-tests/go.mod b/router-tests/go.mod index dbf9fbf1b2..c78c581ece 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -26,7 +26,7 @@ require ( github.com/wundergraph/cosmo/demo/pkg/subgraphs/projects v0.0.0-20250715110703-10f2e5f9c79e github.com/wundergraph/cosmo/router v0.0.0-20251125205644-175f80c4e6d9 github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e - github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245.0.20260126144918-2db07ae63d8d + github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.246 go.opentelemetry.io/otel v1.36.0 go.opentelemetry.io/otel/sdk v1.36.0 go.opentelemetry.io/otel/sdk/metric v1.36.0 diff --git a/router-tests/go.sum b/router-tests/go.sum index c3c99fb4cf..87adbb2977 100644 --- a/router-tests/go.sum +++ b/router-tests/go.sum @@ -352,8 +352,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083 h1:8/D7f8gKxTBjW+SZK4mhxTTBVpxcqeBgWF1Rfmltbfk= github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083/go.mod h1:eOTL6acwctsN4F3b7YE+eE2t8zcJ/doLm9sZzsxxxrE= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245.0.20260126144918-2db07ae63d8d h1:zd91uAVQyELsdTw2K1vUPh/wkXgIrc3a329r2bvbw5w= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245.0.20260126144918-2db07ae63d8d/go.mod h1:mX25ASEQiKamxaFSK6NZihh0oDCigIuzro30up4mFH4= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.246 h1:B4/E6zJ5PbHNqcNw4Bvki7o+alcgyz8L5+Nlq1FZbLM= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.246/go.mod h1:mX25ASEQiKamxaFSK6NZihh0oDCigIuzro30up4mFH4= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= diff --git a/router/go.mod b/router/go.mod index 34a6e577ab..b5bacef894 100644 --- a/router/go.mod +++ b/router/go.mod @@ -31,7 +31,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/twmb/franz-go v1.16.1 - github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245.0.20260126144918-2db07ae63d8d + github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.246 // Do not upgrade, it renames attributes we rely on go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 go.opentelemetry.io/contrib/propagators/b3 v1.23.0 diff --git a/router/go.sum b/router/go.sum index fbaaf52aff..18ae41d475 100644 --- a/router/go.sum +++ b/router/go.sum @@ -322,8 +322,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083 h1:8/D7f8gKxTBjW+SZK4mhxTTBVpxcqeBgWF1Rfmltbfk= github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083/go.mod h1:eOTL6acwctsN4F3b7YE+eE2t8zcJ/doLm9sZzsxxxrE= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245.0.20260126144918-2db07ae63d8d h1:zd91uAVQyELsdTw2K1vUPh/wkXgIrc3a329r2bvbw5w= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245.0.20260126144918-2db07ae63d8d/go.mod h1:mX25ASEQiKamxaFSK6NZihh0oDCigIuzro30up4mFH4= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.246 h1:B4/E6zJ5PbHNqcNw4Bvki7o+alcgyz8L5+Nlq1FZbLM= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.246/go.mod h1:mX25ASEQiKamxaFSK6NZihh0oDCigIuzro30up4mFH4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= From 98e96f3460af92dd69a2399b4fe07471449c94d4 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:01:31 +0200 Subject: [PATCH 07/11] fix the comment in a test --- router-tests/security_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/router-tests/security_test.go b/router-tests/security_test.go index b01f4fdce1..2aba004d2e 100644 --- a/router-tests/security_test.go +++ b/router-tests/security_test.go @@ -512,7 +512,7 @@ func TestQueryNamingLimits(t *testing.T) { ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ Enabled: true, - StaticLimit: 24, + StaticLimit: 10, ListSize: 5, } }, @@ -520,7 +520,7 @@ func TestQueryNamingLimits(t *testing.T) { res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ Query: `{ employees { id details { forename } } }`, }) - // cost = 5 * (5 * 1) + // cost = 5 * (1 + 1) require.Contains(t, res.Body, `"data":`) }) }) From e21a880c36b5945c89f2c9b2ad9d632c3589817b Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:15:17 +0200 Subject: [PATCH 08/11] fix default config --- router/pkg/config/testdata/config_defaults.json | 1 + 1 file changed, 1 insertion(+) diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index b4ddad685e..e31496a117 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -345,6 +345,7 @@ }, "ComplexityCalculationCache": null, "ComplexityLimits": null, + "CostAnalysis": null, "DepthLimit": null, "ParserLimits": { "ApproximateDepthLimit": 200, From 5783522db3f1f51fa7d51584cefe8eebe527f14c Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:45:57 +0200 Subject: [PATCH 09/11] return cost struct instead of the plan value --- .../modules/custom_trace_propagator_test.go | 2 +- .../modules/streams_hooks_combined_test.go | 5 +- .../modules/verify-cost-analysis/module.go | 63 +++++++++++++++++++ .../modules/verify-static-cost/module.go | 63 ------------------- ...t_test.go => verify_cost_analysis_test.go} | 32 +++++----- router/core/context.go | 23 ++++--- router/core/modules.go | 8 +-- router/core/operation_processor.go | 2 +- 8 files changed, 103 insertions(+), 95 deletions(-) create mode 100644 router-tests/modules/verify-cost-analysis/module.go delete mode 100644 router-tests/modules/verify-static-cost/module.go rename router-tests/modules/{verify_static_cost_test.go => verify_cost_analysis_test.go} (65%) diff --git a/router-tests/modules/custom_trace_propagator_test.go b/router-tests/modules/custom_trace_propagator_test.go index ed269a6ff9..5ccc0d5642 100644 --- a/router-tests/modules/custom_trace_propagator_test.go +++ b/router-tests/modules/custom_trace_propagator_test.go @@ -1,4 +1,4 @@ -package module +package module_test import ( "encoding/json" diff --git a/router-tests/modules/streams_hooks_combined_test.go b/router-tests/modules/streams_hooks_combined_test.go index 47a25b48c6..8f189ea529 100644 --- a/router-tests/modules/streams_hooks_combined_test.go +++ b/router-tests/modules/streams_hooks_combined_test.go @@ -1,4 +1,4 @@ -package module +package module_test import ( "encoding/json" @@ -8,6 +8,8 @@ import ( "github.com/hasura/go-graphql-client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + "github.com/wundergraph/cosmo/router-tests/events" stream_publish "github.com/wundergraph/cosmo/router-tests/modules/stream-publish" stream_receive "github.com/wundergraph/cosmo/router-tests/modules/stream-receive" @@ -16,7 +18,6 @@ import ( "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" "github.com/wundergraph/cosmo/router/pkg/pubsub/kafka" - "go.uber.org/zap/zapcore" ) func TestStreamsHooksCombined(t *testing.T) { diff --git a/router-tests/modules/verify-cost-analysis/module.go b/router-tests/modules/verify-cost-analysis/module.go new file mode 100644 index 0000000000..3ff68278ab --- /dev/null +++ b/router-tests/modules/verify-cost-analysis/module.go @@ -0,0 +1,63 @@ +package verify_cost_analysis + +import ( + "net/http" + + "go.uber.org/zap" + + "github.com/wundergraph/cosmo/router/core" +) + +const myModuleID = "verifyCost" + +// CapturedCost holds the captured cost from operation context +type CapturedCost struct { + Cost core.OperationCost + Error error +} + +// VerifyCostModule captures cost for verification in tests +type VerifyCostModule struct { + ResultsChan chan CapturedCost + Logger *zap.Logger +} + +func (m *VerifyCostModule) Provision(ctx *core.ModuleContext) error { + m.Logger = ctx.Logger + if m.ResultsChan == nil { + m.ResultsChan = make(chan CapturedCost, 1) + } + return nil +} + +func (m *VerifyCostModule) Middleware(ctx core.RequestContext, next http.Handler) { + operation := ctx.Operation() + + cost, err := operation.Cost() + captured := CapturedCost{Cost: cost, Error: err} + + // Send the captured values to the test + select { + case m.ResultsChan <- captured: + default: + // Channel is full, skip + } + + next.ServeHTTP(ctx.ResponseWriter(), ctx.Request()) +} + +func (m *VerifyCostModule) Module() core.ModuleInfo { + return core.ModuleInfo{ + ID: myModuleID, + Priority: 1, + New: func() core.Module { + return &VerifyCostModule{ + ResultsChan: make(chan CapturedCost, 1), + } + }, + } +} + +var ( + _ core.RouterMiddlewareHandler = (*VerifyCostModule)(nil) +) diff --git a/router-tests/modules/verify-static-cost/module.go b/router-tests/modules/verify-static-cost/module.go deleted file mode 100644 index aee17f7efd..0000000000 --- a/router-tests/modules/verify-static-cost/module.go +++ /dev/null @@ -1,63 +0,0 @@ -package verify_static_cost - -import ( - "net/http" - - "go.uber.org/zap" - - "github.com/wundergraph/cosmo/router/core" -) - -const myModuleID = "verifyStaticCost" - -// CapturedStaticCost holds the captured static cost from operation context -type CapturedStaticCost struct { - Cost int - Error error -} - -// VerifyStaticCostModule captures static cost for verification in tests -type VerifyStaticCostModule struct { - ResultsChan chan CapturedStaticCost - Logger *zap.Logger -} - -func (m *VerifyStaticCostModule) Provision(ctx *core.ModuleContext) error { - m.Logger = ctx.Logger - if m.ResultsChan == nil { - m.ResultsChan = make(chan CapturedStaticCost, 1) - } - return nil -} - -func (m *VerifyStaticCostModule) Middleware(ctx core.RequestContext, next http.Handler) { - operation := ctx.Operation() - - cost, err := operation.StaticCost() - captured := CapturedStaticCost{Cost: cost, Error: err} - - // Send the captured values to the test - select { - case m.ResultsChan <- captured: - default: - // Channel is full, skip - } - - next.ServeHTTP(ctx.ResponseWriter(), ctx.Request()) -} - -func (m *VerifyStaticCostModule) Module() core.ModuleInfo { - return core.ModuleInfo{ - ID: myModuleID, - Priority: 1, - New: func() core.Module { - return &VerifyStaticCostModule{ - ResultsChan: make(chan CapturedStaticCost, 1), - } - }, - } -} - -var ( - _ core.RouterMiddlewareHandler = (*VerifyStaticCostModule)(nil) -) diff --git a/router-tests/modules/verify_static_cost_test.go b/router-tests/modules/verify_cost_analysis_test.go similarity index 65% rename from router-tests/modules/verify_static_cost_test.go rename to router-tests/modules/verify_cost_analysis_test.go index 44c6a1c08a..89cd3b464d 100644 --- a/router-tests/modules/verify_static_cost_test.go +++ b/router-tests/modules/verify_cost_analysis_test.go @@ -7,24 +7,24 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - verifyModule "github.com/wundergraph/cosmo/router-tests/modules/verify-static-cost" + verifyModule "github.com/wundergraph/cosmo/router-tests/modules/verify-cost-analysis" "github.com/wundergraph/cosmo/router-tests/testenv" "github.com/wundergraph/cosmo/router/core" "github.com/wundergraph/cosmo/router/pkg/config" ) -func TestStaticCostModuleExposition(t *testing.T) { +func TestCostModuleExposition(t *testing.T) { t.Parallel() - t.Run("module can access static cost when cost analysis is enabled", func(t *testing.T) { + t.Run("module can access cost when cost analysis is enabled", func(t *testing.T) { t.Parallel() - resultsChan := make(chan verifyModule.CapturedStaticCost, 1) + resultsChan := make(chan verifyModule.CapturedCost, 1) cfg := config.Config{ Graph: config.Graph{}, Modules: map[string]any{ - "verifyStaticCost": verifyModule.VerifyStaticCostModule{ + "verifyCost": verifyModule.VerifyCostModule{ ResultsChan: resultsChan, }, }, @@ -33,7 +33,7 @@ func TestStaticCostModuleExposition(t *testing.T) { testenv.Run(t, &testenv.Config{ RouterOptions: []core.Option{ core.WithModulesConfig(cfg.Modules), - core.WithCustomModules(&verifyModule.VerifyStaticCostModule{}), + core.WithCustomModules(&verifyModule.VerifyCostModule{}), }, ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ @@ -49,12 +49,12 @@ func TestStaticCostModuleExposition(t *testing.T) { require.NoError(t, err) assert.Equal(t, 200, res.Response.StatusCode) - testenv.AwaitChannelWithT(t, 10*time.Second, resultsChan, func(t *testing.T, captured verifyModule.CapturedStaticCost) { - assert.NoError(t, captured.Error, "StaticCost() should not return an error when cost analysis is enabled") - assert.Greater(t, captured.Cost, 0, "Static cost should be greater than 0 for a query with object fields") + testenv.AwaitChannelWithT(t, 10*time.Second, resultsChan, func(t *testing.T, captured verifyModule.CapturedCost) { + assert.NoError(t, captured.Error, "Cost() should not return an error when cost analysis is enabled") + assert.Greater(t, captured.Cost.Estimated, 0, "Estimated cost should be greater than 0 for a query with object fields") // Log the cost for demonstration purposes - t.Logf("Query static cost: %d (could be used for rate limiting)", captured.Cost) + t.Logf("Query estimated cost: %d (could be used for rate limiting)", captured.Cost.Estimated) // In a real module, you could use this cost for: // - Rate limiting (deduct from user's cost budget) // - Logging/metrics @@ -68,12 +68,12 @@ func TestStaticCostModuleExposition(t *testing.T) { t.Run("module receives error when cost analysis is disabled", func(t *testing.T) { t.Parallel() - resultsChan := make(chan verifyModule.CapturedStaticCost, 1) + resultsChan := make(chan verifyModule.CapturedCost, 1) cfg := config.Config{ Graph: config.Graph{}, Modules: map[string]any{ - "verifyStaticCost": verifyModule.VerifyStaticCostModule{ + "verifyCost": verifyModule.VerifyCostModule{ ResultsChan: resultsChan, }, }, @@ -82,7 +82,7 @@ func TestStaticCostModuleExposition(t *testing.T) { testenv.Run(t, &testenv.Config{ RouterOptions: []core.Option{ core.WithModulesConfig(cfg.Modules), - core.WithCustomModules(&verifyModule.VerifyStaticCostModule{}), + core.WithCustomModules(&verifyModule.VerifyCostModule{}), }, ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = nil @@ -94,9 +94,9 @@ func TestStaticCostModuleExposition(t *testing.T) { require.NoError(t, err) assert.Equal(t, 200, res.Response.StatusCode) - testenv.AwaitChannelWithT(t, 10*time.Second, resultsChan, func(t *testing.T, captured verifyModule.CapturedStaticCost) { - assert.Error(t, captured.Error, "StaticCost() should return an error when cost analysis is disabled") - assert.Equal(t, 0, captured.Cost, "Cost should be 0 when cost analysis is disabled") + testenv.AwaitChannelWithT(t, 10*time.Second, resultsChan, func(t *testing.T, captured verifyModule.CapturedCost) { + assert.Error(t, captured.Error, "Cost() should return an error when cost analysis is disabled") + assert.Equal(t, 0, captured.Cost.Estimated, "Estimated cost should be 0 when cost analysis is disabled") }) }) }) diff --git a/router/core/context.go b/router/core/context.go index 9db8f0ea20..5ef95838ff 100644 --- a/router/core/context.go +++ b/router/core/context.go @@ -503,10 +503,9 @@ type OperationContext interface { // in Middleware is recommended QueryPlanStats() (QueryPlanStats, error) - // StaticCost returns the static (estimated) cost of the operation based on @cost and @listSize directives. - // Returns 0 if cost analysis is not enabled or the plan is not yet available. - // This should be called after planning is complete, using in Middleware is recommended. - StaticCost() (int, error) + // Cost returns cost analysis results for the operation. + // This should be called after planning is complete; using in Middleware is recommended. + Cost() (OperationCost, error) } var _ OperationContext = (*operationContext)(nil) @@ -627,6 +626,12 @@ type QueryPlanStats struct { SubgraphRootFields []SubgraphRootField } +// OperationCost holds cost analysis results for an operation. +type OperationCost struct { + // Estimated is the static cost calculated before execution based on @cost and @listSize directives. + Estimated int +} + type SubgraphRootField struct { SubgraphName string TypeName string @@ -725,17 +730,19 @@ func (o *operationContext) QueryPlanStats() (QueryPlanStats, error) { return qps, nil } -func (o *operationContext) StaticCost() (int, error) { +func (o *operationContext) Cost() (OperationCost, error) { if o == nil || o.preparedPlan == nil || o.preparedPlan.preparedPlan == nil { - return 0, errors.New("operation context or prepared plan is nil") + return OperationCost{}, errors.New("operation context or prepared plan is nil") } costCalc := o.preparedPlan.preparedPlan.GetStaticCostCalculator() if costCalc == nil { - return 0, errors.New("cost analysis is not enabled") + return OperationCost{}, errors.New("cost analysis is not enabled") } - return costCalc.GetStaticCost(o.planConfig, o.variables), nil + return OperationCost{ + Estimated: costCalc.GetStaticCost(o.planConfig, o.variables), + }, nil } type SubgraphResolver struct { diff --git a/router/core/modules.go b/router/core/modules.go index 7cd5507f25..130c5d0eba 100644 --- a/router/core/modules.go +++ b/router/core/modules.go @@ -105,11 +105,11 @@ type RouterMiddlewareHandler interface { } // RouterOnRequestHandler allows you to add middleware that runs before most internal router logic. -// This runs after the creation of the request context and the creatio of the recovery handler. -// This hook is useful if you want to do some custom logic before tracing or authentication, for example -// if you want to manipulate the bearer auth headers or add a header on a condition that can be logged by tracing. +// This runs after the creation of the request context and the creation of the recovery handler. +// This hook is useful if you want to do some custom logic before tracing or authentication, for example, +// if you want to manipulate the bearer auth headers or add a header on a condition that tracing can log. // The same semantics of http.Handler apply here. Don't manipulate / consume the body of the request unless -// you know what you are doing. If you consume the body of the request it will not be available for the next handler. +// you know what you are doing. If you consume the body of the request, it will not be available for the next handler. type RouterOnRequestHandler interface { RouterOnRequest(ctx RequestContext, next http.Handler) } diff --git a/router/core/operation_processor.go b/router/core/operation_processor.go index 6e7ba078cc..8e32549714 100644 --- a/router/core/operation_processor.go +++ b/router/core/operation_processor.go @@ -1385,7 +1385,7 @@ func (o *OperationKit) ValidateStaticCost(preparedPlan plan.Plan, variables *fas if estimatedCost > costAnalysis.StaticLimit { return &httpGraphqlError{ - message: fmt.Sprintf("The estimated query cost %d exceeds the maximum allowed static cost (%d)", estimatedCost, costAnalysis.StaticLimit), + message: fmt.Sprintf("The estimated query cost %d exceeds the maximum allowed static cost %d", estimatedCost, costAnalysis.StaticLimit), statusCode: http.StatusBadRequest, } } From 466a3ca31660f928ca71925c79837cf08687596c Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:07:04 +0200 Subject: [PATCH 10/11] rename config variables --- .../modules/verify_cost_analysis_test.go | 6 +- router-tests/security_test.go | 87 ++++++++++++------- router/core/operation_processor.go | 11 ++- router/pkg/config/config.go | 29 +++++-- router/pkg/config/config.schema.json | 14 ++- 5 files changed, 98 insertions(+), 49 deletions(-) diff --git a/router-tests/modules/verify_cost_analysis_test.go b/router-tests/modules/verify_cost_analysis_test.go index 89cd3b464d..0b6f52675c 100644 --- a/router-tests/modules/verify_cost_analysis_test.go +++ b/router-tests/modules/verify_cost_analysis_test.go @@ -37,9 +37,9 @@ func TestCostModuleExposition(t *testing.T) { }, ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - StaticLimit: 100, - ListSize: 10, + Enabled: true, + Mode: config.CostAnalysisModeMeasure, + ListSize: 10, } }, }, func(t *testing.T, xEnv *testenv.Environment) { diff --git a/router-tests/security_test.go b/router-tests/security_test.go index 2aba004d2e..7954623b21 100644 --- a/router-tests/security_test.go +++ b/router-tests/security_test.go @@ -368,17 +368,18 @@ func TestQueryNamingLimits(t *testing.T) { t.Run("cost analysis", func(t *testing.T) { t.Parallel() - // These tests verify that static cost is calculated using default values + // These tests verify that cost is calculated using default values // when no @cost or @listSize directives are specified in the schema. - t.Run("blocks queries exceeding static cost limit with default values", func(t *testing.T) { + t.Run("enforce mode blocks queries exceeding cost limit", func(t *testing.T) { t.Parallel() testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - StaticLimit: 9, - ListSize: 5, + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + Limit: 9, + ListSize: 5, } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -387,18 +388,19 @@ func TestQueryNamingLimits(t *testing.T) { }) // cost = 5 * (1 + 1) require.Equal(t, 400, res.Response.StatusCode) - require.Contains(t, res.Body, "exceeds the maximum allowed static cost") + require.Contains(t, res.Body, "exceeds the maximum allowed cost") }) }) - t.Run("allows queries under static cost limit with default values", func(t *testing.T) { + t.Run("enforce mode allows queries under cost limit", func(t *testing.T) { t.Parallel() testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - StaticLimit: 11, - ListSize: 5, + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + Limit: 11, + ListSize: 5, } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -411,13 +413,14 @@ func TestQueryNamingLimits(t *testing.T) { }) }) - t.Run("non-list query with defaults", func(t *testing.T) { + t.Run("enforce mode with non-list query", func(t *testing.T) { t.Parallel() testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - StaticLimit: 2, // employee (1) + details (1) = 2 + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + Limit: 2, // employee (1) + details (1) = 2 } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -435,9 +438,10 @@ func TestQueryNamingLimits(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - StaticLimit: 2, - ListSize: 2, // Small list size + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + Limit: 2, + ListSize: 2, // Small list size } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -449,14 +453,15 @@ func TestQueryNamingLimits(t *testing.T) { }) }) - t.Run("blocks queries exceeding static cost limit when list size is zero", func(t *testing.T) { + t.Run("enforce mode blocks when list size is zero (floored to 1)", func(t *testing.T) { t.Parallel() testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - StaticLimit: 1, - ListSize: 0, // will be floored to 1 + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + Limit: 1, + ListSize: 0, // will be floored to 1 } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -465,7 +470,7 @@ func TestQueryNamingLimits(t *testing.T) { }) // cost = 1 * (1 + 1) require.Equal(t, 400, res.Response.StatusCode) - require.Contains(t, res.Body, "exceeds the maximum allowed static cost") + require.Contains(t, res.Body, "exceeds the maximum allowed cost") }) }) @@ -474,9 +479,10 @@ func TestQueryNamingLimits(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: false, - StaticLimit: 1, - ListSize: 10, + Enabled: false, + Mode: config.CostAnalysisModeEnforce, + Limit: 1, + ListSize: 10, } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -488,13 +494,33 @@ func TestQueryNamingLimits(t *testing.T) { }) }) - t.Run("zero limit does not block queries", func(t *testing.T) { + t.Run("measure mode does not block queries", func(t *testing.T) { t.Parallel() testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - StaticLimit: 0, // Zero means no limit applied, but cost is calculated. + Enabled: true, + Mode: config.CostAnalysisModeMeasure, + Limit: 1, // Would block in enforce mode + ListSize: 10, + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employees { id details { forename surname } } }`, + }) + require.Contains(t, res.Body, `"data":`) + }) + }) + + t.Run("enforce mode with zero limit does not block", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + Limit: 0, // Zero limit means no enforcement } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -511,9 +537,10 @@ func TestQueryNamingLimits(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - StaticLimit: 10, - ListSize: 5, + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + Limit: 10, + ListSize: 5, } }, }, func(t *testing.T, xEnv *testenv.Environment) { diff --git a/router/core/operation_processor.go b/router/core/operation_processor.go index 8e32549714..ea215b97a0 100644 --- a/router/core/operation_processor.go +++ b/router/core/operation_processor.go @@ -1372,7 +1372,12 @@ func (o *OperationKit) ValidateStaticCost(preparedPlan plan.Plan, variables *fas return nil } - if costAnalysis.StaticLimit <= 0 { + // Only enforce limits in enforce mode + if costAnalysis.Mode != config.CostAnalysisModeEnforce { + return nil + } + + if costAnalysis.Limit <= 0 { return nil } @@ -1383,9 +1388,9 @@ func (o *OperationKit) ValidateStaticCost(preparedPlan plan.Plan, variables *fas estimatedCost := costCalc.GetStaticCost(o.operationProcessor.executor.PlanConfig, variables) - if estimatedCost > costAnalysis.StaticLimit { + if estimatedCost > costAnalysis.Limit { return &httpGraphqlError{ - message: fmt.Sprintf("The estimated query cost %d exceeds the maximum allowed static cost %d", estimatedCost, costAnalysis.StaticLimit), + message: fmt.Sprintf("The estimated query cost %d exceeds the maximum allowed cost %d", estimatedCost, costAnalysis.Limit), statusCode: http.StatusBadRequest, } } diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 75256fdf2a..f2582f73fe 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -7,13 +7,12 @@ import ( "strings" "time" - "github.com/wundergraph/cosmo/router/internal/yamlmerge" - "github.com/caarlos0/env/v11" "github.com/goccy/go-yaml" "go.uber.org/zap/zapcore" "github.com/wundergraph/cosmo/router/internal/unique" + "github.com/wundergraph/cosmo/router/internal/yamlmerge" "github.com/wundergraph/cosmo/router/pkg/otel/otelconfig" ) @@ -459,19 +458,31 @@ type ComplexityLimits struct { IgnoreIntrospection bool `yaml:"ignore_introspection" envDefault:"false" env:"SECURITY_COMPLEXITY_IGNORE_INTROSPECTION"` } +// CostAnalysisMode defines how cost analysis behaves. +type CostAnalysisMode string + +const ( + CostAnalysisModeMeasure CostAnalysisMode = "measure" + CostAnalysisModeEnforce CostAnalysisMode = "enforce" +) + // CostAnalysis configures cost analysis based on @cost and @listSize directives. type CostAnalysis struct { - // When enabled, static cost is calculated and exposed to modules. - // Set to true to enable StaticLimit option. + // Enabled controls whether cost analysis is active. + // When true, the router calculates cost for every operation. Enabled bool `yaml:"enabled" envDefault:"false" env:"SECURITY_COST_ANALYSIS_ENABLED"` - // StaticLimit is the maximum allowed static (estimated) cost for a query. - // Queries exceeding this limit will be rejected before execution. - // If the limit is 0, this limit isn't applied. - StaticLimit int `yaml:"static_limit,omitempty" envDefault:"0" env:"SECURITY_COST_ANALYSIS_STATIC_LIMIT"` + // Mode controls cost analysis behavior: + // - "measure": calculates costs without rejecting operations (for monitoring) + // - "enforce": calculates costs and rejects operations exceeding the static limit + Mode CostAnalysisMode `yaml:"mode,omitempty" envDefault:"measure" env:"SECURITY_COST_ANALYSIS_MODE"` + + // Limit is the maximum allowed estimated cost for a query. + // Only enforced when Mode is "enforce". Operations exceeding this limit are rejected. + Limit int `yaml:"limit,omitempty" envDefault:"0" env:"SECURITY_COST_ANALYSIS_LIMIT"` // ListSize is the default assumed size for list fields when no @listSize directive - // nor slicing argument is provided. Used as a multiplier for list field static costs. + // nor slicing argument is provided. Used as a multiplier for list field costs. ListSize int `yaml:"list_size,omitempty" envDefault:"10" env:"SECURITY_COST_ANALYSIS_LIST_SIZE"` } diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 64556b1ca5..552e2fecc5 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2691,17 +2691,23 @@ "enabled": { "type": "boolean", "default": false, - "description": "When enabled, static cost is calculated and exposed to modules. Set to true to enable StaticLimit option." + "description": "When enabled, the router calculates cost for every operation." }, - "static_limit": { + "mode": { + "type": "string", + "enum": ["measure", "enforce"], + "default": "measure", + "description": "Controls cost analysis behavior: 'measure' calculates costs without rejecting operations; 'enforce' rejects operations exceeding the limit." + }, + "limit": { "type": "integer", - "description": "The maximum allowed static (estimated) cost for a query. Queries exceeding this limit will be rejected before execution. If the limit is 0, this limit isn't applied.", + "description": "Maximum allowed estimated cost for a query. Only enforced when mode is 'enforce'.", "default": 0, "minimum": 0 }, "list_size": { "type": "integer", - "description": "The default assumed size for list fields when no @listSize directive nor slicing argument is provided. Used as a multiplier for list field static costs.", + "description": "Default assumed size for list fields when no @listSize directive is specified.", "default": 10, "minimum": 1 } From f7eb41b81ac198672742d6aea11f3793861d65b2 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:19:37 +0200 Subject: [PATCH 11/11] rename config vars --- .../modules/verify_cost_analysis_test.go | 6 +- router-tests/security_test.go | 84 +++++++++---------- router/core/executor.go | 2 +- router/core/operation_processor.go | 6 +- router/pkg/config/config.go | 16 ++-- router/pkg/config/config.schema.json | 10 +-- 6 files changed, 62 insertions(+), 62 deletions(-) diff --git a/router-tests/modules/verify_cost_analysis_test.go b/router-tests/modules/verify_cost_analysis_test.go index 0b6f52675c..f967895c7a 100644 --- a/router-tests/modules/verify_cost_analysis_test.go +++ b/router-tests/modules/verify_cost_analysis_test.go @@ -37,9 +37,9 @@ func TestCostModuleExposition(t *testing.T) { }, ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - Mode: config.CostAnalysisModeMeasure, - ListSize: 10, + Enabled: true, + Mode: config.CostAnalysisModeMeasure, + EstimatedListSize: 10, } }, }, func(t *testing.T, xEnv *testenv.Environment) { diff --git a/router-tests/security_test.go b/router-tests/security_test.go index 7954623b21..cd83d7ae39 100644 --- a/router-tests/security_test.go +++ b/router-tests/security_test.go @@ -371,15 +371,15 @@ func TestQueryNamingLimits(t *testing.T) { // These tests verify that cost is calculated using default values // when no @cost or @listSize directives are specified in the schema. - t.Run("enforce mode blocks queries exceeding cost limit", func(t *testing.T) { + t.Run("enforce mode blocks queries exceeding estimated cost limit", func(t *testing.T) { t.Parallel() testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - Mode: config.CostAnalysisModeEnforce, - Limit: 9, - ListSize: 5, + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 9, + EstimatedListSize: 5, } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -388,19 +388,19 @@ func TestQueryNamingLimits(t *testing.T) { }) // cost = 5 * (1 + 1) require.Equal(t, 400, res.Response.StatusCode) - require.Contains(t, res.Body, "exceeds the maximum allowed cost") + require.Contains(t, res.Body, "exceeds the maximum allowed estimated cost") }) }) - t.Run("enforce mode allows queries under cost limit", func(t *testing.T) { + t.Run("enforce mode allows queries under estimated cost limit", func(t *testing.T) { t.Parallel() testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - Mode: config.CostAnalysisModeEnforce, - Limit: 11, - ListSize: 5, + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 11, + EstimatedListSize: 5, } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -418,9 +418,9 @@ func TestQueryNamingLimits(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - Mode: config.CostAnalysisModeEnforce, - Limit: 2, // employee (1) + details (1) = 2 + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 2, // employee (1) + details (1) = 2 } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -433,15 +433,15 @@ func TestQueryNamingLimits(t *testing.T) { }) }) - t.Run("list size configuration affects cost calculation", func(t *testing.T) { + t.Run("estimated list size configuration affects cost calculation", func(t *testing.T) { t.Parallel() testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - Mode: config.CostAnalysisModeEnforce, - Limit: 2, - ListSize: 2, // Small list size + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 2, + EstimatedListSize: 2, // Small list size } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -453,15 +453,15 @@ func TestQueryNamingLimits(t *testing.T) { }) }) - t.Run("enforce mode blocks when list size is zero (floored to 1)", func(t *testing.T) { + t.Run("enforce mode blocks when estimated list size is zero (floored to 1)", func(t *testing.T) { t.Parallel() testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - Mode: config.CostAnalysisModeEnforce, - Limit: 1, - ListSize: 0, // will be floored to 1 + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 1, + EstimatedListSize: 0, // will be floored to 1 } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -470,7 +470,7 @@ func TestQueryNamingLimits(t *testing.T) { }) // cost = 1 * (1 + 1) require.Equal(t, 400, res.Response.StatusCode) - require.Contains(t, res.Body, "exceeds the maximum allowed cost") + require.Contains(t, res.Body, "exceeds the maximum allowed estimated cost") }) }) @@ -479,10 +479,10 @@ func TestQueryNamingLimits(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: false, - Mode: config.CostAnalysisModeEnforce, - Limit: 1, - ListSize: 10, + Enabled: false, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 1, + EstimatedListSize: 10, } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -499,10 +499,10 @@ func TestQueryNamingLimits(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - Mode: config.CostAnalysisModeMeasure, - Limit: 1, // Would block in enforce mode - ListSize: 10, + Enabled: true, + Mode: config.CostAnalysisModeMeasure, + EstimatedLimit: 1, // Would block in enforce mode + EstimatedListSize: 10, } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -513,14 +513,14 @@ func TestQueryNamingLimits(t *testing.T) { }) }) - t.Run("enforce mode with zero limit does not block", func(t *testing.T) { + t.Run("enforce mode with zero estimated limit does not block", func(t *testing.T) { t.Parallel() testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - Mode: config.CostAnalysisModeEnforce, - Limit: 0, // Zero limit means no enforcement + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 0, // Zero limit means no enforcement } }, }, func(t *testing.T, xEnv *testenv.Environment) { @@ -531,16 +531,16 @@ func TestQueryNamingLimits(t *testing.T) { }) }) - t.Run("nested list fields multiply cost", func(t *testing.T) { + t.Run("nested list fields multiply estimated cost", func(t *testing.T) { // Just one additional test; more thorough testing is done in the engine. t.Parallel() testenv.Run(t, &testenv.Config{ ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { securityConfiguration.CostAnalysis = &config.CostAnalysis{ - Enabled: true, - Mode: config.CostAnalysisModeEnforce, - Limit: 10, - ListSize: 5, + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 10, + EstimatedListSize: 5, } }, }, func(t *testing.T, xEnv *testenv.Environment) { diff --git a/router/core/executor.go b/router/core/executor.go index ae106ee204..0363bb2b83 100644 --- a/router/core/executor.go +++ b/router/core/executor.go @@ -246,7 +246,7 @@ func (b *ExecutorConfigurationBuilder) buildPlannerConfiguration(ctx context.Con // Enable static cost computation when cost analysis is enabled if routerEngineCfg.CostAnalysis != nil && routerEngineCfg.CostAnalysis.Enabled { planConfig.ComputeStaticCost = true - planConfig.StaticCostDefaultListSize = routerEngineCfg.CostAnalysis.ListSize + planConfig.StaticCostDefaultListSize = routerEngineCfg.CostAnalysis.EstimatedListSize } return planConfig, providers, nil diff --git a/router/core/operation_processor.go b/router/core/operation_processor.go index ea215b97a0..7dc805d8cd 100644 --- a/router/core/operation_processor.go +++ b/router/core/operation_processor.go @@ -1377,7 +1377,7 @@ func (o *OperationKit) ValidateStaticCost(preparedPlan plan.Plan, variables *fas return nil } - if costAnalysis.Limit <= 0 { + if costAnalysis.EstimatedLimit <= 0 { return nil } @@ -1388,9 +1388,9 @@ func (o *OperationKit) ValidateStaticCost(preparedPlan plan.Plan, variables *fas estimatedCost := costCalc.GetStaticCost(o.operationProcessor.executor.PlanConfig, variables) - if estimatedCost > costAnalysis.Limit { + if estimatedCost > costAnalysis.EstimatedLimit { return &httpGraphqlError{ - message: fmt.Sprintf("The estimated query cost %d exceeds the maximum allowed cost %d", estimatedCost, costAnalysis.Limit), + message: fmt.Sprintf("The estimated query cost %d exceeds the maximum allowed estimated cost %d", estimatedCost, costAnalysis.EstimatedLimit), statusCode: http.StatusBadRequest, } } diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index f2582f73fe..7d169b0a8f 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -469,21 +469,21 @@ const ( // CostAnalysis configures cost analysis based on @cost and @listSize directives. type CostAnalysis struct { // Enabled controls whether cost analysis is active. - // When true, the router calculates cost for every operation. + // When true, the router calculates costs for every operation. Enabled bool `yaml:"enabled" envDefault:"false" env:"SECURITY_COST_ANALYSIS_ENABLED"` // Mode controls cost analysis behavior: // - "measure": calculates costs without rejecting operations (for monitoring) - // - "enforce": calculates costs and rejects operations exceeding the static limit + // - "enforce": calculates costs and rejects operations exceeding the estimated limit Mode CostAnalysisMode `yaml:"mode,omitempty" envDefault:"measure" env:"SECURITY_COST_ANALYSIS_MODE"` - // Limit is the maximum allowed estimated cost for a query. - // Only enforced when Mode is "enforce". Operations exceeding this limit are rejected. - Limit int `yaml:"limit,omitempty" envDefault:"0" env:"SECURITY_COST_ANALYSIS_LIMIT"` + // EstimatedLimit is the maximum allowed estimated cost for a query. + // Requires Mode set to "enforce". Operations exceeding this limit are rejected. + EstimatedLimit int `yaml:"estimated_limit,omitempty" envDefault:"0" env:"SECURITY_COST_ANALYSIS_ESTIMATED_LIMIT"` - // ListSize is the default assumed size for list fields when no @listSize directive - // nor slicing argument is provided. Used as a multiplier for list field costs. - ListSize int `yaml:"list_size,omitempty" envDefault:"10" env:"SECURITY_COST_ANALYSIS_LIST_SIZE"` + // EstimatedListSize is the default assumed size for list fields when no @listSize directive + // nor slicing argument is provided. Used as a multiplier for estimated cost calculation. + EstimatedListSize int `yaml:"estimated_list_size,omitempty" envDefault:"10" env:"SECURITY_COST_ANALYSIS_ESTIMATED_LIST_SIZE"` } type ComplexityLimit struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 552e2fecc5..7984a39df0 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2691,23 +2691,23 @@ "enabled": { "type": "boolean", "default": false, - "description": "When enabled, the router calculates cost for every operation." + "description": "When enabled, the router calculates costs for every operation." }, "mode": { "type": "string", "enum": ["measure", "enforce"], "default": "measure", - "description": "Controls cost analysis behavior: 'measure' calculates costs without rejecting operations; 'enforce' rejects operations exceeding the limit." + "description": "Controls cost analysis behavior: 'measure' calculates costs without rejecting operations; 'enforce' rejects operations exceeding the estimated cost limit." }, - "limit": { + "estimated_limit": { "type": "integer", "description": "Maximum allowed estimated cost for a query. Only enforced when mode is 'enforce'.", "default": 0, "minimum": 0 }, - "list_size": { + "estimated_list_size": { "type": "integer", - "description": "Default assumed size for list fields when no @listSize directive is specified.", + "description": "Default assumed size for list fields when no @listSize directive is specified. Used for estimated cost calculation.", "default": 10, "minimum": 1 }