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..0525a6b --- /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 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. \ No newline at end of file diff --git a/graphql/extension/extension.go b/graphql/extension/extension.go new file mode 100644 index 0000000..946fffd --- /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/extension_test.go b/graphql/extension/extension_test.go new file mode 100644 index 0000000..a4a7921 --- /dev/null +++ b/graphql/extension/extension_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/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{} + } +} 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! +}