Skip to content

Commit 0da433f

Browse files
rickbijkerkRick Bijkerkldebruijn
authored
add depth limiting (#22)
Add config option to configure max allowed depth for queries: example config: ``` max_depth: enable: true # The maximum number of allowed aliases within a single request. max: 3 # Reject the request when the rule fails. Disable this to allow the request reject_on_failure: true ``` For example the query below has a depth of 4. ``` query{ activeExperiments { variants { experimentKey allocations { end } } } } ``` With the config above it would fail with error: ``` { "data": {}, "errors": [ { "message": "syntax error: Depth limit of 3 exceeded, found 4", "locations": [ { "line": 1, "column": 1 } ] } ] } ``` --------- Co-authored-by: Rick Bijkerk <[email protected]> Co-authored-by: Lars de Bruijn <[email protected]>
1 parent e0459f1 commit 0da433f

File tree

8 files changed

+273
-1
lines changed

8 files changed

+273
-1
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ It is dead-simple yet highly customizable security sidecar compatible with any H
1313
* [Block Field Suggestions](docs/block_field_suggestions.md)
1414
* [Max Aliases](docs/max_aliases.md)
1515
* [Max Tokens](docs/max_tokens.md)
16-
* _Max Depth (coming soon)_
16+
* [Max Depth](docs/max_depth.md)
1717
* _Max Directives (coming soon)_
1818
* _Cost Limit (coming soon)_
1919

cmd/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/ldebruijn/go-graphql-armor/internal/business/aliases"
1212
"github.com/ldebruijn/go-graphql-armor/internal/business/block_field_suggestions"
1313
"github.com/ldebruijn/go-graphql-armor/internal/business/gql"
14+
"github.com/ldebruijn/go-graphql-armor/internal/business/max_depth"
1415
middleware2 "github.com/ldebruijn/go-graphql-armor/internal/business/middleware"
1516
"github.com/ldebruijn/go-graphql-armor/internal/business/persisted_operations"
1617
"github.com/ldebruijn/go-graphql-armor/internal/business/proxy"
@@ -183,6 +184,7 @@ func middleware(log *slog.Logger, cfg *config.Config, po *persisted_operations.P
183184
httpInstrumentation := HTTPInstrumentation()
184185

185186
aliases.NewMaxAliasesRule(cfg.MaxAliases)
187+
max_depth.NewMaxDepthRule(cfg.MaxDepth)
186188
tks := tokens.MaxTokens(cfg.MaxTokens)
187189
vr := ValidationRules(schema, tks, cfg.ObfuscateValidationErrors)
188190

docs/configuration.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ max_aliases:
6969
block_field_suggestions:
7070
enabled: true
7171
mask: [redacted]
72+
73+
max_depth:
74+
enable: true
75+
# The maximum allowed depth within a single request.
76+
max: 15
77+
# Reject the request when the rule fails. Disable this to allow the request
78+
reject_on_failure: false
7279

7380
max_tokens:
7481
# Enable the feature

docs/max_depth.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Max depth
2+
3+
Restricting the maximum depth of operations that are allowed within a single operation to protect your API from abuse.
4+
5+
<!-- TOC -->
6+
7+
## Configuration
8+
9+
You can configure `go-graphql-armor` to limit the maximum depth on an operation.
10+
11+
```yaml
12+
max_depth:
13+
# Enable the feature
14+
enable: "true"
15+
# The maximum depth allowed within a single request.
16+
max: 15
17+
# Reject the request when the rule fails. Disable this to allow the request
18+
reject_on_failure: "true"
19+
```
20+
21+
## Metrics
22+
23+
This rule produces metrics to help you gain insights into the behavior of the rule.
24+
25+
```
26+
go_graphql_armor_max_depth_results{result}
27+
```
28+
29+
30+
| `result` | Description |
31+
|---------|--------------------------------------------------------------------------------------------------------------|
32+
| `allowed` | The rule condition succeeded |
33+
| `rejected` | The rule condition failed and the request was rejected |
34+
| `failed` | The rule condition failed but the request was not rejected. This happens when `reject_on_failure` is `false` |
35+
36+
No metrics are produced when the rule is disabled.

internal/app/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/ardanlabs/conf/v3/yaml"
88
"github.com/ldebruijn/go-graphql-armor/internal/business/aliases"
99
"github.com/ldebruijn/go-graphql-armor/internal/business/block_field_suggestions"
10+
"github.com/ldebruijn/go-graphql-armor/internal/business/max_depth"
1011
"github.com/ldebruijn/go-graphql-armor/internal/business/persisted_operations"
1112
"github.com/ldebruijn/go-graphql-armor/internal/business/proxy"
1213
"github.com/ldebruijn/go-graphql-armor/internal/business/schema"
@@ -33,6 +34,7 @@ type Config struct {
3334
BlockFieldSuggestions block_field_suggestions.Config `yaml:"block_field_suggestions"`
3435
MaxTokens tokens.Config `yaml:"max_tokens"`
3536
MaxAliases aliases.Config `yaml:"max_aliases"`
37+
MaxDepth max_depth.Config `yaml:"max_depth"`
3638
}
3739

3840
func NewConfig(configPath string) (*Config, error) {

internal/business/aliases/aliases.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ func countSelectionSet(set ast.SelectionSet) int {
7777

7878
for _, selection := range set {
7979
if v, ok := selection.(*ast.Field); ok {
80+
// When a query has no alias defined it defaults to the name of the query
8081
if v.Alias != "" && v.Alias != v.Name {
8182
count++
8283
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package max_depth // nolint:revive
2+
3+
import (
4+
"fmt"
5+
"github.com/prometheus/client_golang/prometheus"
6+
"github.com/vektah/gqlparser/v2/ast"
7+
"github.com/vektah/gqlparser/v2/validator"
8+
)
9+
10+
var resultCounter = prometheus.NewCounterVec(prometheus.CounterOpts{
11+
Namespace: "go_graphql_armor",
12+
Subsystem: "max_depth",
13+
Name: "results",
14+
Help: "The results of the max_depth rule",
15+
},
16+
[]string{"result"},
17+
)
18+
19+
type Config struct {
20+
Enabled bool `conf:"default:true" yaml:"enabled"`
21+
Max int `conf:"default:15" yaml:"max"`
22+
RejectOnFailure bool `conf:"default:true" yaml:"reject_on_failure"`
23+
}
24+
25+
func init() {
26+
prometheus.MustRegister(resultCounter)
27+
}
28+
29+
func NewMaxDepthRule(cfg Config) {
30+
if cfg.Enabled {
31+
validator.AddRule("MaxDepth", func(observers *validator.Events, addError validator.AddErrFunc) {
32+
observers.OnOperation(func(walker *validator.Walker, operation *ast.OperationDefinition) {
33+
var maxDepth = countDepth(operation.SelectionSet)
34+
35+
if maxDepth > cfg.Max {
36+
if cfg.RejectOnFailure {
37+
err := fmt.Sprintf("syntax error: Depth limit of %d exceeded, found %d", cfg.Max, maxDepth)
38+
addError(
39+
validator.Message(err),
40+
validator.At(operation.Position),
41+
)
42+
resultCounter.WithLabelValues("rejected").Inc()
43+
} else {
44+
resultCounter.WithLabelValues("failed").Inc()
45+
}
46+
} else {
47+
resultCounter.WithLabelValues("allowed").Inc()
48+
}
49+
})
50+
})
51+
}
52+
}
53+
54+
func countDepth(selectionSet ast.SelectionSet) int {
55+
if selectionSet == nil {
56+
return 0
57+
}
58+
59+
depth := 1
60+
61+
for _, selection := range selectionSet {
62+
switch v := selection.(type) {
63+
case *ast.Field:
64+
selectionDepth := countDepth(v.SelectionSet) + 1
65+
if selectionDepth > depth {
66+
depth = selectionDepth
67+
}
68+
case *ast.FragmentSpread:
69+
selectionDepth := countDepth(v.Definition.SelectionSet)
70+
if selectionDepth > depth {
71+
depth = selectionDepth
72+
}
73+
}
74+
}
75+
return depth
76+
77+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package max_depth // nolint:revive
2+
3+
import (
4+
"fmt"
5+
"github.com/stretchr/testify/assert"
6+
"github.com/vektah/gqlparser/v2"
7+
"github.com/vektah/gqlparser/v2/ast"
8+
"github.com/vektah/gqlparser/v2/parser"
9+
"github.com/vektah/gqlparser/v2/validator"
10+
"testing"
11+
)
12+
13+
func Test_MaxDepthRule(t *testing.T) {
14+
schema := `
15+
type Query {
16+
getBook(title: String): Book
17+
}
18+
19+
type Book {
20+
id: ID!
21+
title: String
22+
author: Author!
23+
price: Price!
24+
}
25+
type Author {
26+
id: ID!
27+
name: String
28+
}
29+
type Price {
30+
price: Int!
31+
id: ID!
32+
}
33+
`
34+
type args struct {
35+
query string
36+
schema string
37+
cfg Config
38+
}
39+
tests := []struct {
40+
name string
41+
args args
42+
want error
43+
}{
44+
{
45+
name: "no query yields zero count",
46+
args: args{
47+
query: "",
48+
schema: schema,
49+
cfg: Config{
50+
Max: 15,
51+
Enabled: true,
52+
},
53+
},
54+
want: nil,
55+
},
56+
{
57+
name: "Calculate the depth properly with fragments",
58+
args: args{
59+
cfg: Config{
60+
Max: 3,
61+
Enabled: true,
62+
RejectOnFailure: true,
63+
},
64+
query: `
65+
query A {
66+
getBook(title: "null") {
67+
id
68+
...BookFragment
69+
}
70+
}
71+
fragment BookFragment on Book {
72+
author {
73+
name
74+
}
75+
}`,
76+
schema: schema,
77+
},
78+
want: nil,
79+
},
80+
{
81+
name: "Calculate depth properly",
82+
args: args{
83+
cfg: Config{
84+
Enabled: true,
85+
Max: 2,
86+
RejectOnFailure: true,
87+
},
88+
query: `
89+
query {
90+
getBook(title: "null") {
91+
title
92+
price {
93+
price
94+
id
95+
}
96+
}
97+
}`,
98+
schema: schema,
99+
},
100+
want: fmt.Errorf("syntax error: Depth limit of %d exceeded, found %d", 2, 3),
101+
},
102+
{
103+
name: "Works correctly with fragments",
104+
args: args{
105+
cfg: Config{
106+
Max: 2,
107+
Enabled: true,
108+
RejectOnFailure: true,
109+
},
110+
query: `
111+
query A {
112+
getBook(title: "null") {
113+
id
114+
...BookFragment
115+
}
116+
}
117+
fragment BookFragment on Book {
118+
author {
119+
name
120+
}
121+
}`,
122+
schema: schema,
123+
},
124+
want: fmt.Errorf("syntax error: Depth limit of %d exceeded, found %d", 2, 3),
125+
},
126+
}
127+
for _, tt := range tests {
128+
t.Run(tt.name, func(t *testing.T) {
129+
NewMaxDepthRule(tt.args.cfg)
130+
131+
query, _ := parser.ParseQuery(&ast.Source{Name: "ff", Input: tt.args.query})
132+
schema := gqlparser.MustLoadSchema(&ast.Source{
133+
Name: "graph/schema.graphqls",
134+
Input: tt.args.schema,
135+
BuiltIn: false,
136+
})
137+
138+
errs := validator.Validate(schema, query)
139+
140+
if tt.want == nil {
141+
assert.Empty(t, errs)
142+
} else {
143+
assert.Equal(t, tt.want.Error(), errs[0].Message)
144+
}
145+
})
146+
}
147+
}

0 commit comments

Comments
 (0)