Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(security): add middleware to limit top level operation amount #90

Merged
merged 12 commits into from
Dec 22, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
flamingo.me/flamingo/v3 v3.7.0
github.com/99designs/gqlgen v0.17.41
github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.8.4
github.com/vektah/gqlparser/v2 v2.5.10
go.opencensus.io v0.24.0
)
Expand All @@ -20,6 +21,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cockroachdb/apd/v2 v2.0.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-kit/log v0.2.1 // indirect
Expand All @@ -37,6 +39,7 @@ require (
github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de // indirect
github.com/openzipkin/zipkin-go v0.4.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.13.0 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
Expand Down
8 changes: 3 additions & 5 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,12 @@ flamingo.me/dingo v0.2.10 h1:FAOsDtKHA+0KmAIdJYzsFNMMOCOMdVX4uAL8JPo6Z8M=
flamingo.me/dingo v0.2.10/go.mod h1:5motgtzS2t26y20nndTLH7tslkBMg4GivTTn+ICQkl8=
flamingo.me/flamingo/v3 v3.7.0 h1:XVIzVCkfexSooaFtXIV5DKqlTkvndsWMhdExxqXxMMI=
flamingo.me/flamingo/v3 v3.7.0/go.mod h1:zGLA1HuC1/EMPsO8G8rrWsGhRUFDE2mmcIcZRUZAX6o=
github.com/99designs/gqlgen v0.17.40 h1:/l8JcEVQ93wqIfmH9VS1jsAkwm6eAF1NwQn3N+SDqBY=
github.com/99designs/gqlgen v0.17.40/go.mod h1:b62q1USk82GYIVjC60h02YguAZLqYZtvWml8KkhJps4=
github.com/99designs/gqlgen v0.17.41 h1:C1/zYMhGVP5TWNCNpmZ9Mb6CqT1Vr5SHEWoTOEJ3v3I=
github.com/99designs/gqlgen v0.17.41/go.mod h1:GQ6SyMhwFbgHR0a8r2Wn8fYgEwPxxmndLFPhU63+cJE=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
Expand All @@ -80,6 +79,7 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
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=
Expand Down Expand Up @@ -113,7 +113,6 @@ github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/cockroachdb/apd/v2 v2.0.1 h1:y1Rh3tEU89D+7Tgbw+lp52T6p/GJLpDmNvr10UWqLTE=
github.com/cockroachdb/apd/v2 v2.0.1/go.mod h1:DDxRlzC2lo3/vSlmSoS7JkqbbrARPuFOGr0B9pvN3Gw=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
Expand Down Expand Up @@ -357,8 +356,6 @@ github.com/sosodev/duration v1.1.0 h1:kQcaiGbJaIsRqgQy7VGlZrVw1giWO+lDoX3MCPnpVO
github.com/sosodev/duration v1.1.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
Expand All @@ -380,6 +377,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stvp/go-udp-testing v0.0.0-20201019212854-469649b16807/go.mod h1:7jxmlfBCDBXRzr0eAQJ48XC1hBu1np4CS5+cHEYfwpc=
github.com/uber/jaeger-client-go v2.25.0+incompatible h1:IxcNZ7WRY1Y3G4poYlx24szfsn/3LvK9QHCq9oQw8+U=
github.com/uber/jaeger-client-go v2.25.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
Expand Down
75 changes: 75 additions & 0 deletions limitQueryAmountMiddleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package graphql
KarolNowakk marked this conversation as resolved.
Show resolved Hide resolved

import (
"context"

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

const (
sameOperationsDefaultThreshold = 2
allOperationsDefaultThreshold = 10
)

KarolNowakk marked this conversation as resolved.
Show resolved Hide resolved
func LimitQueryAmountMiddleware(
KarolNowakk marked this conversation as resolved.
Show resolved Hide resolved
cfg *struct {
SameOperationsThreshold int `inject:"config:graphql.limitQueryAmountMiddleware.sameOperationsThreshold,optional"`
AllOperationsThreshold int `inject:"config:graphql.limitQueryAmountMiddleware.allOperationsThreshold,optional"`
},
) func(ctx context.Context, next gql.OperationHandler) gql.ResponseHandler {
return func(ctx context.Context, next gql.OperationHandler) gql.ResponseHandler {
sameOperationsThreshold := sameOperationsDefaultThreshold
allOperationsThreshold := allOperationsDefaultThreshold

if cfg != nil {
sameOperationsThreshold = cfg.SameOperationsThreshold
allOperationsThreshold = cfg.AllOperationsThreshold
}

req := gql.GetOperationContext(ctx)

occurrences := countTopLevelGraphQLOperations(req.Operation.SelectionSet)

if isAboveThreshold(sameOperationsThreshold, allOperationsThreshold, occurrences) {
return func(ctx context.Context) *gql.Response {
return gql.ErrorResponse(ctx, "request not allowed")
}
}

return next(ctx)
}
}

func countTopLevelGraphQLOperations(definition []ast.Selection) map[string]int {
mapOfOccurrences := make(map[string]int)

for _, set := range definition {
field, ok := set.(*ast.Field)
if !ok {
continue
}

if _, exists := mapOfOccurrences[field.Name]; !exists {
mapOfOccurrences[field.Name] = 0
}

mapOfOccurrences[field.Name]++
}

return mapOfOccurrences
}

func isAboveThreshold(threshold, operationsThreshold int, operations map[string]int) bool {
if len(operations) > operationsThreshold {
return true
}

for _, operationsNumber := range operations {
if operationsNumber > threshold {
return true
}
}

return false
}
108 changes: 108 additions & 0 deletions limitQueryAmountMiddleware_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package graphql_test

import (
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/99designs/gqlgen/graphql/handler/testserver"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/stretchr/testify/assert"

"flamingo.me/graphql"
)

func Test_LimitQueryAmountMiddleware(t *testing.T) {
t.Parallel()

t.Run("deny when there is too many same operations called", func(t *testing.T) {
t.Parallel()

srv := testserver.New()

srv.AddTransport(transport.GET{})
srv.AddTransport(transport.POST{})

srv.AroundOperations(graphql.LimitQueryAmountMiddleware(
&struct {
SameOperationsThreshold int `inject:"config:graphql.limitQueryAmountMiddleware.sameOperationsThreshold,optional"`
AllOperationsThreshold int `inject:"config:graphql.limitQueryAmountMiddleware.allOperationsThreshold,optional"`
}{
SameOperationsThreshold: 2,
AllOperationsThreshold: 10,
}))

body := `{
"query": "query { user1: name user2: name user3: name user4: name user5: name }"
}`

resp := doRequest(srv, "POST", "/graphql", body)
assert.Equal(t, http.StatusOK, resp.Code, resp.Body.String())
assert.Equal(t, `{"errors":[{"message":"request not allowed"}],"data":null}`, resp.Body.String())
})

t.Run("deny when there are too many different operations invoked in one query", func(t *testing.T) {
t.Parallel()

srv := testserver.New()

srv.AddTransport(transport.GET{})
srv.AddTransport(transport.POST{})

srv.AroundOperations(graphql.LimitQueryAmountMiddleware(
&struct {
SameOperationsThreshold int `inject:"config:graphql.limitQueryAmountMiddleware.sameOperationsThreshold,optional"`
AllOperationsThreshold int `inject:"config:graphql.limitQueryAmountMiddleware.allOperationsThreshold,optional"`
}{
SameOperationsThreshold: 27,
AllOperationsThreshold: 0,
}))

body := `{
"query": "query { user1: name user2: name user3: name user4: name user5: name }"
}`

resp := doRequest(srv, "POST", "/graphql", body)
assert.Equal(t, http.StatusOK, resp.Code, resp.Body.String())
assert.Equal(t, `{"errors":[{"message":"request not allowed"}],"data":null}`, resp.Body.String())
})

t.Run("allow when request is below both thresholds", func(t *testing.T) {
t.Parallel()

srv := testserver.New()

srv.AddTransport(transport.GET{})
srv.AddTransport(transport.POST{})

srv.AroundOperations(graphql.LimitQueryAmountMiddleware(
&struct {
SameOperationsThreshold int `inject:"config:graphql.limitQueryAmountMiddleware.sameOperationsThreshold,optional"`
AllOperationsThreshold int `inject:"config:graphql.limitQueryAmountMiddleware.allOperationsThreshold,optional"`
}{
SameOperationsThreshold: 10,
AllOperationsThreshold: 10,
}))

body := `{
"query": "query { user1: name user2: name }"
}`

resp := doRequest(srv, "POST", "/graphql", body)
assert.Equal(t, http.StatusOK, resp.Code, resp.Body.String())
assert.Equal(t, `{"data":{"name":"test"}}`, resp.Body.String())
})
}

func doRequest(handler http.Handler, method string, target string, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, target, strings.NewReader(body))

r.Header.Set("Content-Type", "application/json")

w := httptest.NewRecorder()

handler.ServeHTTP(w, r)

return w
}
31 changes: 27 additions & 4 deletions module.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ import (
"time"

"flamingo.me/dingo"
flamingoConfig "flamingo.me/flamingo/v3/framework/config"
"flamingo.me/flamingo/v3/framework/web"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/lru"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/spf13/cobra"

flamingoConfig "flamingo.me/flamingo/v3/framework/config"
"flamingo.me/flamingo/v3/framework/web"
)

// Service defines the interface for graphql services
Expand All @@ -24,12 +25,29 @@ type Service interface {
}

// Module defines the graphql entry point and binds the graphql command and routes
type Module struct{}
type Module struct {
enableLimitQueryAmountMiddleware bool
}

// Inject executable schema
func (m *Module) Inject(
config *struct {
EnableLimitQueryAmountMiddleware bool `inject:"config:graphql.limitQueryAmountMiddleware.enable,optional"`
},
) {
if config != nil {
m.enableLimitQueryAmountMiddleware = config.EnableLimitQueryAmountMiddleware
}
}

// Configure sets up dingo
func (*Module) Configure(injector *dingo.Injector) {
func (m *Module) Configure(injector *dingo.Injector) {
injector.BindMulti(new(cobra.Command)).ToProvider(command)

if m.enableLimitQueryAmountMiddleware {
injector.BindMulti(new(graphql.OperationMiddleware)).ToProvider(LimitQueryAmountMiddleware)
}

web.BindRoutes(injector, new(routes))
}

Expand Down Expand Up @@ -136,6 +154,11 @@ graphql: {
multipartForm: {
uploadMaxSize: (int | *1.5M) & > 0
}
limitQueryAmountMiddleware: {
KarolNowakk marked this conversation as resolved.
Show resolved Hide resolved
enable: bool | *false
KarolNowakk marked this conversation as resolved.
Show resolved Hide resolved
sameOperationsThreshold: number | *2
allOperationsThreshold: number | *10
}
}
`
}
Loading