From 07a47daedfc9663924bf4da421fce763c01bc3ed Mon Sep 17 00:00:00 2001 From: Tomas Zahradnicek Date: Tue, 16 Apr 2024 14:47:23 +0200 Subject: [PATCH 1/3] feat: graphql recursion limit extension --- go.mod | 4 ++ go.sum | 18 +++++ graphql/extension/README.md | 46 +++++++++++++ graphql/extension/extension.go | 77 ++++++++++++++++++++++ graphql/extension/extenstion_test.go | 91 ++++++++++++++++++++++++++ graphql/extension/test/queries.graphql | 71 ++++++++++++++++++++ graphql/extension/test/schema.graphqls | 14 ++++ 7 files changed, 321 insertions(+) create mode 100644 graphql/extension/README.md create mode 100644 graphql/extension/extension.go create mode 100644 graphql/extension/extenstion_test.go create mode 100644 graphql/extension/test/queries.graphql create mode 100644 graphql/extension/test/schema.graphqls diff --git a/go.mod b/go.mod index f9ceb67..fadd7df 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index b64c081..9fffb45 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= diff --git a/graphql/extension/README.md b/graphql/extension/README.md new file mode 100644 index 0000000..f94bcc4 --- /dev/null +++ b/graphql/extension/README.md @@ -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 firends 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. \ No newline at end of file diff --git a/graphql/extension/extension.go b/graphql/extension/extension.go new file mode 100644 index 0000000..6d027a4 --- /dev/null +++ b/graphql/extension/extension.go @@ -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 +} diff --git a/graphql/extension/extenstion_test.go b/graphql/extension/extenstion_test.go new file mode 100644 index 0000000..5982d0b --- /dev/null +++ b/graphql/extension/extenstion_test.go @@ -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/require" + "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, + }) + require.Equal(t, err, tt.expectedErr) + }) + } +} + +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{} + } +} diff --git a/graphql/extension/test/queries.graphql b/graphql/extension/test/queries.graphql new file mode 100644 index 0000000..840f1b0 --- /dev/null +++ b/graphql/extension/test/queries.graphql @@ -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 + } + } + } + } +} diff --git a/graphql/extension/test/schema.graphqls b/graphql/extension/test/schema.graphqls new file mode 100644 index 0000000..072fdb6 --- /dev/null +++ b/graphql/extension/test/schema.graphqls @@ -0,0 +1,14 @@ +type User { + id: String! + friends: [User!]! + items: [Item!]! +} + +type Item { + id: String! + owners: [User!]! +} + +type Query { + user: User! +} From 90a1df71d506007df6a930952bde164557e2ee21 Mon Sep 17 00:00:00 2001 From: Tomas Zahradnicek Date: Tue, 16 Apr 2024 14:57:28 +0200 Subject: [PATCH 2/3] typo --- graphql/extension/{extenstion_test.go => extension_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename graphql/extension/{extenstion_test.go => extension_test.go} (100%) diff --git a/graphql/extension/extenstion_test.go b/graphql/extension/extension_test.go similarity index 100% rename from graphql/extension/extenstion_test.go rename to graphql/extension/extension_test.go From adfc84e0bda057445499f2237d2b6903b3713326 Mon Sep 17 00:00:00 2001 From: Tomas Zahradnicek Date: Mon, 22 Apr 2024 13:51:09 +0200 Subject: [PATCH 3/3] fix: recursion counting, other minor PR fixes --- graphql/extension/README.md | 2 +- graphql/extension/extension.go | 2 +- graphql/extension/extension_test.go | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/graphql/extension/README.md b/graphql/extension/README.md index f94bcc4..0525a6b 100644 --- a/graphql/extension/README.md +++ b/graphql/extension/README.md @@ -11,7 +11,7 @@ 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 firends may return a list). +consider that both root `user` and `User.friends` returns a type `User` (although friends may return a list). Allows: ```graphql diff --git a/graphql/extension/extension.go b/graphql/extension/extension.go index 6d027a4..946fffd 100644 --- a/graphql/extension/extension.go +++ b/graphql/extension/extension.go @@ -65,7 +65,7 @@ func checkRecursionLimitByTypeAndField(rCtx recursionContext, typeName string, s if newCount > rCtx.maxRecursion { return gqlerror.Errorf("too many nesting on %s.%s", nesting.parentTypeName, nesting.childFieldName) } - rCtx.typeAndFieldCount[nesting] += newCount + rCtx.typeAndFieldCount[nesting] = newCount err := checkRecursionLimitByTypeAndField(rCtx, collectedField.Definition.Type.Name(), collectedField.SelectionSet) if err != nil { return err diff --git a/graphql/extension/extension_test.go b/graphql/extension/extension_test.go index 5982d0b..a4a7921 100644 --- a/graphql/extension/extension_test.go +++ b/graphql/extension/extension_test.go @@ -7,7 +7,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/executor" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" "github.com/vektah/gqlparser/v2" "github.com/vektah/gqlparser/v2/ast" "github.com/vektah/gqlparser/v2/gqlerror" @@ -62,7 +62,7 @@ func TestRecursionLimitByTypeAndField(t *testing.T) { Query: queries, OperationName: tt.operationName, }) - require.Equal(t, err, tt.expectedErr) + assert.Equal(t, tt.expectedErr, err) }) } } @@ -72,7 +72,7 @@ var sources = []*ast.Source{ } var parsedSchema = gqlparser.MustLoadSchema(sources...) -var _ graphql.ExecutableSchema = &executableSchema{} +var _ graphql.ExecutableSchema = executableSchema{} type executableSchema struct{}