Skip to content

Commit

Permalink
feat: graphql recursion limit extension (#32)
Browse files Browse the repository at this point in the history
* feat: graphql recursion limit extension

* typo

* fix: recursion counting, other minor PR fixes
  • Loading branch information
Fazt01 committed May 1, 2024
1 parent 6e8c0ef commit 28d324b
Show file tree
Hide file tree
Showing 7 changed files with 321 additions and 0 deletions.
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@ module go.strv.io/net
go 1.22

require (
github.com/99designs/gqlgen v0.17.45
github.com/go-chi/chi/v5 v5.0.12
github.com/google/uuid v1.6.0
github.com/stretchr/testify v1.9.0
github.com/vektah/gqlparser/v2 v2.5.11
go.strv.io/time v0.2.0
)

require (
github.com/agnivade/levenshtein v1.1.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/sosodev/duration v1.2.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
18 changes: 18 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
github.com/99designs/gqlgen v0.17.45 h1:bH0AH67vIJo8JKNKPJP+pOPpQhZeuVRQLf53dKIpDik=
github.com/99designs/gqlgen v0.17.45/go.mod h1:Bas0XQ+Jiu/Xm5E33jC8sES3G+iC2esHBMXcq0fUPs0=
github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
Expand All @@ -18,12 +28,20 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sosodev/duration v1.2.0 h1:pqK/FLSjsAADWY74SyWDCjOcd5l7H8GSnnOGEB9A1Us=
github.com/sosodev/duration v1.2.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/vektah/gqlparser/v2 v2.5.11 h1:JJxLtXIoN7+3x6MBdtIP59TP1RANnY7pXOaDnADQSf8=
github.com/vektah/gqlparser/v2 v2.5.11/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc=
go.strv.io/time v0.2.0 h1:RgCpABq+temfp8+DLM2zqsdimnKpktOSPduUghM8ZIk=
go.strv.io/time v0.2.0/go.mod h1:B/lByAO3oACN3uLOXQaB64cKhkVIMoZjnZBhADFNbFY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
46 changes: 46 additions & 0 deletions graphql/extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
This package includes an extension that can be used as `"github.com/99designs/gqlgen/graphql".HandlerExtension`,
e.g. in `"github.com/99designs/gqlgen/graphql/handler".Server.Use`.

The extension `RecursionLimitByTypeAndField` limits the number of times the same field of a type can be accessed
in a request (query/mutation).

Usage:
```go
gqlServer := handler.New()
gqlServer.Use(RecursionLimitByTypeAndField(1))
```

This allow only one of each "type.field" field access in a query. For following examples,
consider that both root `user` and `User.friends` returns a type `User` (although friends may return a list).

Allows:
```graphql
query {
user {
id
friends {
id
}
}
}
```

Forbids:
```graphql
query {
user {
friends {
friends {
id
}
}
}
}
```

`User.friends` is accessed twice here. Once in `user.friends`, and second time on `friends.friends`.


The intention of this extension is to replace `extension.FixedComplexityLimit`, as that is very difficult to configure
properly. With `RecursionLimitByTypeAndField`, the client can query the whole graph in one query, but at least
the query does have an upper bound of its size. If needed, both extensions can be used at the same time.
77 changes: 77 additions & 0 deletions graphql/extension/extension.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package extension

import (
"context"

"github.com/99designs/gqlgen/graphql"
"github.com/vektah/gqlparser/v2/ast"
"github.com/vektah/gqlparser/v2/gqlerror"
)

type RecursionLimit struct {
maxRecursion int
}

func RecursionLimitByTypeAndField(limit int) *RecursionLimit {
return &RecursionLimit{
maxRecursion: limit,
}
}

var _ interface {
graphql.OperationContextMutator
graphql.HandlerExtension
} = &RecursionLimit{}

func (r *RecursionLimit) ExtensionName() string {
return "RecursionLimit"
}

func (r *RecursionLimit) Validate(_ graphql.ExecutableSchema) error {
return nil
}

func (r *RecursionLimit) MutateOperationContext(_ context.Context, opCtx *graphql.OperationContext) *gqlerror.Error {
return checkRecursionLimitByTypeAndField(recursionContext{
maxRecursion: r.maxRecursion,
opCtx: opCtx,
typeAndFieldCount: map[nestingByTypeAndField]int{},
}, string(opCtx.Operation.Operation), opCtx.Operation.SelectionSet)
}

type nestingByTypeAndField struct {
parentTypeName string
childFieldName string
}

type recursionContext struct {
maxRecursion int
opCtx *graphql.OperationContext
typeAndFieldCount map[nestingByTypeAndField]int
}

func checkRecursionLimitByTypeAndField(rCtx recursionContext, typeName string, selectionSet ast.SelectionSet) *gqlerror.Error {
if selectionSet == nil {
return nil
}

collected := graphql.CollectFields(rCtx.opCtx, selectionSet, nil)
for _, collectedField := range collected {
nesting := nestingByTypeAndField{
parentTypeName: typeName,
childFieldName: collectedField.Name,
}
newCount := rCtx.typeAndFieldCount[nesting] + 1
if newCount > rCtx.maxRecursion {
return gqlerror.Errorf("too many nesting on %s.%s", nesting.parentTypeName, nesting.childFieldName)
}
rCtx.typeAndFieldCount[nesting] = newCount
err := checkRecursionLimitByTypeAndField(rCtx, collectedField.Definition.Type.Name(), collectedField.SelectionSet)
if err != nil {
return err
}
rCtx.typeAndFieldCount[nesting] -= 1
}

return nil
}
91 changes: 91 additions & 0 deletions graphql/extension/extension_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package extension

import (
"context"
_ "embed"
"testing"

"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/executor"
"github.com/stretchr/testify/assert"
"github.com/vektah/gqlparser/v2"
"github.com/vektah/gqlparser/v2/ast"
"github.com/vektah/gqlparser/v2/gqlerror"
)

var (
//go:embed test/schema.graphqls
schema string
//go:embed test/queries.graphql
queries string
)

func TestRecursionLimitByTypeAndField(t *testing.T) {
tests := []struct {
operationName string
expectedErr gqlerror.List
}{
{
operationName: "Allowed",
expectedErr: nil,
},
{
operationName: "RecursionExceeded",
expectedErr: gqlerror.List{{
Message: "too many nesting on User.friends",
}},
},
{
operationName: "InterleavedTypesAllowed",
expectedErr: nil,
},
{
operationName: "InterleavedTypesRecursionExceeded",
expectedErr: gqlerror.List{{
Message: "too many nesting on User.items",
}},
},

{
operationName: "DifferentSubtreeAllowed",
expectedErr: nil,
},
}

for _, tt := range tests {
t.Run(tt.operationName, func(t *testing.T) {
exec := executor.New(executableSchema{})
exec.Use(RecursionLimitByTypeAndField(1))
ctx := context.Background()
ctx = graphql.StartOperationTrace(ctx)
_, err := exec.CreateOperationContext(ctx, &graphql.RawParams{
Query: queries,
OperationName: tt.operationName,
})
assert.Equal(t, tt.expectedErr, err)
})
}
}

var sources = []*ast.Source{
{Name: "schema.graphqls", Input: schema, BuiltIn: false},
}
var parsedSchema = gqlparser.MustLoadSchema(sources...)

var _ graphql.ExecutableSchema = executableSchema{}

type executableSchema struct{}

func (e executableSchema) Schema() *ast.Schema {
return parsedSchema
}

func (e executableSchema) Complexity(_, _ string, _ int, _ map[string]interface{}) (int, bool) {
return 0, false
}

func (e executableSchema) Exec(_ context.Context) graphql.ResponseHandler {
return func(ctx context.Context) *graphql.Response {
return &graphql.Response{}
}
}
71 changes: 71 additions & 0 deletions graphql/extension/test/queries.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
query Allowed {
user {
id
friends {
id
}
}
}

query RecursionExceeded {
user {
id
friends {
id
friends {
id
}
}
}
}

query InterleavedTypesAllowed {
user {
id
items {
id
owners {
id
}
}
}
}

query InterleavedTypesRecursionExceeded {
user {
id
items {
id
owners {
id
items {
id
}
}
}
}
}

query DifferentSubtreeAllowed {
user {
id
friends {
id
items {
id
owners {
id
}
}
}
items {
id
owners {
id
friends {
id
}
}
}
}
}
14 changes: 14 additions & 0 deletions graphql/extension/test/schema.graphqls
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
type User {
id: String!
friends: [User!]!
items: [Item!]!
}

type Item {
id: String!
owners: [User!]!
}

type Query {
user: User!
}

0 comments on commit 28d324b

Please sign in to comment.