Skip to content

Commit 20dd212

Browse files
AdrienPoupaendigma
andauthored
feat(router): add security complexity mode (#2753)
Co-authored-by: Jesse <endigma@mailcat.ca>
1 parent eb669fb commit 20dd212

9 files changed

Lines changed: 329 additions & 13 deletions

File tree

docs-website/router/configuration.mdx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2046,6 +2046,7 @@ The configuration for the security. The security is used to configure the securi
20462046
| SECURITY_BLOCK_PERSISTED_OPERATIONS_CONDITION | block_persisted_operations.condition | <Icon icon="square" /> | The [expression](/router/configuration/template-expressions) to evaluate if the operation should be blocked. | |
20472047
| | complexity_calculation_cache | <Icon icon="square" /> | Complexity Cache configuration | |
20482048
| | complexity_limits | <Icon icon="square" /> | Complexity limits configuration | |
2049+
| | complexity_limits.mode | <Icon icon="square" /> | `measure` calculates complexity without rejecting; `enforce` rejects operations exceeding limits. | enforce |
20492050
| | parser_limits.approximate_depth_limit | <Icon icon="square" /> | The approximate cumulative depth limit of a query, including fragments. Set to 0 to disable. | 200 |
20502051
| | parser_limits.total_fields_limit | <Icon icon="square" /> | The total number of fields the parser will allow. Set to 0 to disable. | 3500 |
20512052
| SECURITY_OPERATION_NAME_LENGTH_LIMIT | operation_name_length_limit | <Icon icon="square" /> | The maximum allowed length for the operation name. Set to 0 to disable. | 512 |
@@ -2070,7 +2071,8 @@ security:
20702071
enabled: true
20712072
size: 1024
20722073
complexity_limits:
2073-
depth: # the equivalent of `security.depth_limit` prevoiusly
2074+
mode: enforce # 'measure' calculates complexity without rejecting; 'enforce' rejects operations exceeding limits
2075+
depth: # the equivalent of `security.depth_limit` previously
20742076
enabled: true
20752077
limit: 7
20762078
ignore_persisted_operations: true
@@ -2141,13 +2143,19 @@ The configuration for adding a complexity limits for queries. We currently expos
21412143
- **Root Fields in Query**
21422144
- **Root Field Aliases in Query**
21432145

2144-
For all of the limits, if the limit is 0, or `enabled` isn't true, the limit isn't applied. All of them have the same configuration fields:
2146+
A limit is not applied when its value is 0 or `enabled` is not true.
21452147

2146-
| Environment Variable | YAML | Required | Description | Default Value |
2147-
| -------------------- | --------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
2148-
| | enabled | <Icon icon="square" /> | Enable the specific limit. If the value is true (default: false), and a valid limit value is set, the limit will be applied | false |
2149-
| | limit | <Icon icon="square" /> | The limit amount for query. If the limit is 0, this limit isn't applied | 0 |
2150-
| | ignore_persisted_operations | <Icon icon="square" /> | Disable the limit for persisted operations. Since persisted operations are stored intentionally, users may want to disable the limit to consciously allow nested persisted operations | false |
2148+
The `mode` field controls enforcement versus measurement:
2149+
2150+
- `measure`: calculates complexity and reports it via telemetry but does not reject requests.
2151+
- `enforce` (default): rejects requests that exceed limits.
2152+
2153+
| Environment Variable | YAML | Required | Description | Default Value |
2154+
| ------------------------ | --------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
2155+
| | mode | <Icon icon="square" /> | `measure` calculates complexity without rejecting; `enforce` rejects operations exceeding limits. | enforce |
2156+
| | enabled | <Icon icon="square" /> | Enable the specific limit. If the value is true (default: false), and a valid limit value is set, the limit will be applied. | false |
2157+
| | limit | <Icon icon="square" /> | The limit amount for query. If the limit is 0, this limit is not applied. | 0 |
2158+
| | ignore_persisted_operations | <Icon icon="square" /> | Disable the limit for persisted operations. Since persisted operations are stored intentionally, users may want to disable the limit to consciously allow nested persisted operations. | false |
21512159

21522160
## File Upload
21532161

router-tests/operations/complexity_limits_test.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,4 +534,203 @@ func TestComplexityLimits(t *testing.T) {
534534
})
535535
})
536536
})
537+
538+
t.Run("measure mode", func(t *testing.T) {
539+
t.Parallel()
540+
541+
t.Run("measure mode does not block queries exceeding depth limit", func(t *testing.T) {
542+
t.Parallel()
543+
testenv.Run(t, &testenv.Config{
544+
ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) {
545+
securityConfiguration.ComplexityLimits = &config.ComplexityLimits{
546+
Mode: config.ComplexityLimitsModeMeasure,
547+
Depth: &config.ComplexityLimit{
548+
Enabled: true,
549+
Limit: 2,
550+
},
551+
}
552+
},
553+
}, func(t *testing.T, xEnv *testenv.Environment) {
554+
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
555+
Query: `{ employee(id:1) { id details { forename surname } } }`,
556+
})
557+
require.JSONEq(t, `{"data":{"employee":{"id":1,"details":{"forename":"Jens","surname":"Neuse"}}}}`, res.Body)
558+
})
559+
})
560+
561+
t.Run("measure mode does not block queries exceeding total fields limit", func(t *testing.T) {
562+
t.Parallel()
563+
testenv.Run(t, &testenv.Config{
564+
ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) {
565+
securityConfiguration.ComplexityLimits = &config.ComplexityLimits{
566+
Mode: config.ComplexityLimitsModeMeasure,
567+
TotalFields: &config.ComplexityLimit{
568+
Enabled: true,
569+
Limit: 1,
570+
},
571+
}
572+
},
573+
}, func(t *testing.T, xEnv *testenv.Environment) {
574+
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
575+
Query: `{ employee(id:1) { id details { forename surname } } }`,
576+
})
577+
require.JSONEq(t, `{"data":{"employee":{"id":1,"details":{"forename":"Jens","surname":"Neuse"}}}}`, res.Body)
578+
})
579+
})
580+
581+
t.Run("measure mode does not block queries exceeding root fields limit", func(t *testing.T) {
582+
t.Parallel()
583+
testenv.Run(t, &testenv.Config{
584+
ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) {
585+
securityConfiguration.ComplexityLimits = &config.ComplexityLimits{
586+
Mode: config.ComplexityLimitsModeMeasure,
587+
RootFields: &config.ComplexityLimit{
588+
Enabled: true,
589+
Limit: 2,
590+
},
591+
}
592+
},
593+
}, func(t *testing.T, xEnv *testenv.Environment) {
594+
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
595+
Query: `query { initialPayload employee(id:1) { id } employees { id } }`,
596+
})
597+
require.Contains(t, res.Body, `"initialPayload"`)
598+
})
599+
})
600+
601+
t.Run("measure mode does not block queries exceeding root field aliases limit", func(t *testing.T) {
602+
t.Parallel()
603+
testenv.Run(t, &testenv.Config{
604+
ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) {
605+
securityConfiguration.ComplexityLimits = &config.ComplexityLimits{
606+
Mode: config.ComplexityLimitsModeMeasure,
607+
RootFieldAliases: &config.ComplexityLimit{
608+
Enabled: true,
609+
Limit: 1,
610+
},
611+
}
612+
},
613+
}, func(t *testing.T, xEnv *testenv.Environment) {
614+
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
615+
Query: `query { firstemployee: employee(id:1) { id } employee2: employee(id:2) { id } }`,
616+
})
617+
require.Equal(t, `{"data":{"firstemployee":{"id":1},"employee2":{"id":2}}}`, res.Body)
618+
})
619+
})
620+
621+
t.Run("measure mode still reports complexity in OTel spans", func(t *testing.T) {
622+
t.Parallel()
623+
624+
exporter := tracetest.NewInMemoryExporter(t)
625+
testenv.Run(t, &testenv.Config{
626+
TraceExporter: exporter,
627+
ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) {
628+
securityConfiguration.ComplexityLimits = &config.ComplexityLimits{
629+
Mode: config.ComplexityLimitsModeMeasure,
630+
Depth: &config.ComplexityLimit{
631+
Enabled: true,
632+
Limit: 2,
633+
},
634+
}
635+
},
636+
}, func(t *testing.T, xEnv *testenv.Environment) {
637+
// Query exceeds depth limit of 2 (actual depth is 3) but should succeed in measure mode
638+
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
639+
Query: `{ employee(id:1) { id details { forename surname } } }`,
640+
})
641+
require.JSONEq(t, `{"data":{"employee":{"id":1,"details":{"forename":"Jens","surname":"Neuse"}}}}`, res.Body)
642+
643+
testSpan := testutils.RequireSpanWithName(t, exporter, "Operation - Validate")
644+
require.Contains(t, testSpan.Attributes(), otel.WgQueryDepth.Int(3))
645+
require.Contains(t, testSpan.Attributes(), otel.WgQueryDepthCacheHit.Bool(false))
646+
})
647+
})
648+
649+
t.Run("measure mode caches complexity and reports cache hit", func(t *testing.T) {
650+
t.Parallel()
651+
652+
exporter := tracetest.NewInMemoryExporter(t)
653+
testenv.Run(t, &testenv.Config{
654+
TraceExporter: exporter,
655+
ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) {
656+
securityConfiguration.ComplexityLimits = &config.ComplexityLimits{
657+
Mode: config.ComplexityLimitsModeMeasure,
658+
Depth: &config.ComplexityLimit{
659+
Enabled: true,
660+
Limit: 2,
661+
},
662+
}
663+
securityConfiguration.ComplexityCalculationCache = &config.ComplexityCalculationCache{
664+
Enabled: true,
665+
CacheSize: 1024,
666+
}
667+
},
668+
}, func(t *testing.T, xEnv *testenv.Environment) {
669+
// First request - should compute and cache
670+
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
671+
Query: `{ employee(id:1) { id details { forename surname } } }`,
672+
})
673+
require.JSONEq(t, `{"data":{"employee":{"id":1,"details":{"forename":"Jens","surname":"Neuse"}}}}`, res.Body)
674+
675+
testSpan := testutils.RequireSpanWithName(t, exporter, "Operation - Validate")
676+
require.Contains(t, testSpan.Attributes(), otel.WgQueryDepth.Int(3))
677+
require.Contains(t, testSpan.Attributes(), otel.WgQueryDepthCacheHit.Bool(false))
678+
exporter.Reset()
679+
680+
// Wait for cache consistency
681+
time.Sleep(100 * time.Millisecond)
682+
683+
// Second request - should use cache and still succeed in measure mode
684+
res2 := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
685+
Query: `{ employee(id:1) { id details { forename surname } } }`,
686+
})
687+
require.JSONEq(t, `{"data":{"employee":{"id":1,"details":{"forename":"Jens","surname":"Neuse"}}}}`, res2.Body)
688+
689+
testSpan2 := testutils.RequireSpanWithName(t, exporter, "Operation - Validate")
690+
require.Contains(t, testSpan2.Attributes(), otel.WgQueryDepth.Int(3))
691+
require.Contains(t, testSpan2.Attributes(), otel.WgQueryDepthCacheHit.Bool(true))
692+
})
693+
})
694+
695+
t.Run("enforce mode still blocks queries (default behavior)", func(t *testing.T) {
696+
t.Parallel()
697+
testenv.Run(t, &testenv.Config{
698+
ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) {
699+
securityConfiguration.ComplexityLimits = &config.ComplexityLimits{
700+
Mode: config.ComplexityLimitsModeEnforce,
701+
Depth: &config.ComplexityLimit{
702+
Enabled: true,
703+
Limit: 2,
704+
},
705+
}
706+
},
707+
}, func(t *testing.T, xEnv *testenv.Environment) {
708+
res, _ := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{
709+
Query: `{ employee(id:1) { id details { forename surname } } }`,
710+
})
711+
require.Equal(t, 400, res.Response.StatusCode)
712+
require.Equal(t, `{"errors":[{"message":"The query depth 3 exceeds the max query depth allowed (2)"}]}`, res.Body)
713+
})
714+
})
715+
716+
t.Run("default mode is enforce and blocks queries", func(t *testing.T) {
717+
t.Parallel()
718+
testenv.Run(t, &testenv.Config{
719+
ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) {
720+
securityConfiguration.ComplexityLimits = &config.ComplexityLimits{
721+
Depth: &config.ComplexityLimit{
722+
Enabled: true,
723+
Limit: 2,
724+
},
725+
}
726+
},
727+
}, func(t *testing.T, xEnv *testenv.Environment) {
728+
res, _ := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{
729+
Query: `{ employee(id:1) { id details { forename surname } } }`,
730+
})
731+
require.Equal(t, 400, res.Response.StatusCode)
732+
require.Equal(t, `{"errors":[{"message":"The query depth 3 exceeds the max query depth allowed (2)"}]}`, res.Body)
733+
})
734+
})
735+
})
537736
}

router/core/operation_processor.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,6 +1342,9 @@ func (o *OperationKit) ValidateQueryComplexity() (ok bool, cacheEntry Complexity
13421342

13431343
if o.cache != nil && o.cache.complexityCache != nil {
13441344
if cachedComplexity, found := o.cache.complexityCache.Get(o.parsedOperation.InternalID); found {
1345+
if limits.Mode == config.ComplexityLimitsModeMeasure {
1346+
return true, cachedComplexity, nil
1347+
}
13451348
return true, cachedComplexity, o.runComplexityComparisons(limits, cachedComplexity, o.parsedOperation.IsPersistedOperation)
13461349
}
13471350
}
@@ -1365,6 +1368,10 @@ func (o *OperationKit) ValidateQueryComplexity() (ok bool, cacheEntry Complexity
13651368
o.cache.complexityCache.Set(o.parsedOperation.InternalID, cacheResult, 1)
13661369
}
13671370

1371+
if limits.Mode == config.ComplexityLimitsModeMeasure {
1372+
return false, cacheResult, nil
1373+
}
1374+
13681375
return false, cacheResult, o.runComplexityComparisons(limits, cacheResult, o.parsedOperation.IsPersistedOperation)
13691376
}
13701377

router/core/router.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,9 @@ func NewRouter(opts ...Option) (*Router, error) {
539539
}
540540

541541
if r.securityConfiguration.ComplexityLimits == nil {
542-
r.securityConfiguration.ComplexityLimits = &config.ComplexityLimits{}
542+
r.securityConfiguration.ComplexityLimits = &config.ComplexityLimits{
543+
Mode: config.ComplexityLimitsModeEnforce,
544+
}
543545
}
544546
if r.securityConfiguration.ComplexityLimits.Depth == nil {
545547
r.securityConfiguration.ComplexityLimits.Depth = &config.ComplexityLimit{

router/pkg/config/config.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,21 @@ type ComplexityCalculationCache struct {
496496
CacheSize int64 `yaml:"size,omitempty" envDefault:"1024" env:"SECURITY_COMPLEXITY_CACHE_SIZE"`
497497
}
498498

499+
// ComplexityLimitsMode defines how complexity limits behave.
500+
type ComplexityLimitsMode string
501+
502+
const (
503+
ComplexityLimitsModeUnset ComplexityLimitsMode = ""
504+
ComplexityLimitsModeMeasure ComplexityLimitsMode = "measure"
505+
ComplexityLimitsModeEnforce ComplexityLimitsMode = "enforce"
506+
)
507+
499508
type ComplexityLimits struct {
509+
// Mode controls complexity limits behavior:
510+
// - "measure": calculates complexity without rejecting operations (for monitoring)
511+
// - "enforce": calculates complexity and rejects operations exceeding limits
512+
Mode ComplexityLimitsMode `yaml:"mode,omitempty"`
513+
500514
Depth *ComplexityLimit `yaml:"depth"`
501515
TotalFields *ComplexityLimit `yaml:"total_fields"`
502516
RootFields *ComplexityLimit `yaml:"root_fields"`

router/pkg/config/config.schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2936,6 +2936,12 @@
29362936
"description": "The configuration for complexity limits for queries",
29372937
"additionalProperties": false,
29382938
"properties": {
2939+
"mode": {
2940+
"type": "string",
2941+
"enum": ["measure", "enforce"],
2942+
"default": "enforce",
2943+
"description": "Controls complexity limits behavior: 'measure' calculates complexity without rejecting operations; 'enforce' rejects operations exceeding limits."
2944+
},
29392945
"depth": {
29402946
"type": "object",
29412947
"description": "The configuration for adding a max depth limit for query (how many nested levels you can have in a query). This limit prevents infinite querying, and also limits the size of the data returned. If the limit is 0, this limit isn't applied.",

0 commit comments

Comments
 (0)