diff --git a/lib/request-processor/context/cache.go b/lib/request-processor/context/cache.go index 5eb5e4d2f..fed9ce90f 100644 --- a/lib/request-processor/context/cache.go +++ b/lib/request-processor/context/cache.go @@ -8,6 +8,7 @@ import ( "main/log" "main/utils" "strconv" + "strings" ) /* @@ -248,3 +249,40 @@ func ContextSetIsEndpointIpAllowed() { func ContextSetIsEndpointRateLimited() { Context.IsEndpointRateLimited = true } + +func ContextSetGraphQL() { + if Context.GraphQLParsedFlattened != nil { + return + } + + Context.GraphQLParsedFlattened = &map[string]string{} + + method := GetMethod() + url := GetUrl() + + // Get content-type from headers + var contentType string + headers := GetHeadersParsed() + if ct, ok := headers["content_type"].(string); ok { + contentType = ct + } else { + contentType = "" + } + + contentType = strings.ToLower(strings.TrimSpace(contentType)) + body := GetBodyParsed() + query := GetQueryParsed() + + isGraphQL := utils.IsGraphQLOverHTTP(method, url, contentType, body, query) + + if isGraphQL { + log.Debug("Detected GraphQL request") + + // Extract GraphQL inputs + graphqlInputs := utils.ExtractInputsFromGraphQL(body, query, method) + Context.GraphQLParsedFlattened = &graphqlInputs + + return + } + +} diff --git a/lib/request-processor/context/data_sources.go b/lib/request-processor/context/data_sources.go index 6aaae6972..03ff27726 100644 --- a/lib/request-processor/context/data_sources.go +++ b/lib/request-processor/context/data_sources.go @@ -11,4 +11,5 @@ var SOURCES = []Source{ {"headers", GetHeadersParsedFlattened}, {"cookies", GetCookiesParsedFlattened}, {"routeParams", GetRouteParamsParsedFlattened}, + {"graphql", GetGraphQLParsedFlattened}, } diff --git a/lib/request-processor/context/request_context.go b/lib/request-processor/context/request_context.go index 893783b32..c858b44af 100644 --- a/lib/request-processor/context/request_context.go +++ b/lib/request-processor/context/request_context.go @@ -42,6 +42,7 @@ type RequestContextData struct { RouteParamsRaw *string RouteParamsParsed *map[string]interface{} RouteParamsParsedFlattened *map[string]string + GraphQLParsedFlattened *map[string]string } var Context RequestContextData @@ -141,6 +142,10 @@ func GetHeadersParsedFlattened() map[string]string { return GetFromCache(ContextSetHeaders, &Context.HeadersParsedFlattened) } +func GetGraphQLParsedFlattened() map[string]string { + return GetFromCache(ContextSetGraphQL, &Context.GraphQLParsedFlattened) +} + func GetUserAgent() string { return GetFromCache(ContextSetUserAgent, &Context.UserAgent) } diff --git a/lib/request-processor/go.mod b/lib/request-processor/go.mod index c6ecb4baf..977fd0e06 100644 --- a/lib/request-processor/go.mod +++ b/lib/request-processor/go.mod @@ -3,18 +3,19 @@ module main go 1.25.6 require ( + github.com/graphql-go/graphql v0.8.1 github.com/stretchr/testify v1.11.1 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba - google.golang.org/grpc v1.77.0 + golang.org/x/net v0.47.0 + google.golang.org/grpc v1.78.0 google.golang.org/protobuf v1.36.10 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/lib/request-processor/go.sum b/lib/request-processor/go.sum index 20f0ca096..e6c0f30e9 100644 --- a/lib/request-processor/go.sum +++ b/lib/request-processor/go.sum @@ -1,99 +1,57 @@ 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/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc= +github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 h1:6/3JGEh1C88g7m+qzzTbl3A0FtsLguXieqofVLU/JAo= golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= -google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= -google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= -google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= -google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= -google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/lib/request-processor/utils/graphql.go b/lib/request-processor/utils/graphql.go new file mode 100644 index 000000000..c319dcc18 --- /dev/null +++ b/lib/request-processor/utils/graphql.go @@ -0,0 +1,252 @@ +package utils + +import ( + "encoding/json" + "main/helpers" + "main/log" + "strings" + + "github.com/graphql-go/graphql/language/ast" + "github.com/graphql-go/graphql/language/parser" + "github.com/graphql-go/graphql/language/source" + "github.com/graphql-go/graphql/language/visitor" +) + +// IsGraphQLOverHTTP checks if the current request is a GraphQL over HTTP request +func IsGraphQLOverHTTP( + method string, + url string, + contentType string, + body map[string]interface{}, + query map[string]interface{}, +) bool { + if method == "POST" { + return isGraphQLRoute(url) && + isJSONContentType(contentType) && + hasGraphQLQuery(body) && + looksLikeGraphQLQuery(extractQueryString(body)) + } + + if method == "GET" { + queryStr := extractQueryString(query) + return isGraphQLRoute(url) && + queryStr != "" && + looksLikeGraphQLQuery(queryStr) + } + + return false +} + +// isGraphQLRoute checks if the URL path contains graphql +// Matches common patterns like /graphql, /api/graphql, /graphql/api, etc. +func isGraphQLRoute(url string) bool { + if url == "" { + return false + } + urlLower := strings.ToLower(url) + return strings.Contains(urlLower, "/graphql") || strings.Contains(urlLower, "graphql/") +} + +// isJSONContentType checks if the content type is JSON +func isJSONContentType(contentType string) bool { + if contentType == "" { + return false + } + contentTypeLower := strings.ToLower(contentType) + return strings.Contains(contentTypeLower, "application/json") || + strings.Contains(contentTypeLower, "application/graphql") +} + +// hasGraphQLQuery checks if body has a query field that is a string +func hasGraphQLQuery(body map[string]interface{}) bool { + if body == nil { + return false + } + queryField, exists := body["query"] + if !exists { + return false + } + _, ok := queryField.(string) + return ok +} + +// extractQueryString extracts the query string from a map (body or query params) +func extractQueryString(data map[string]interface{}) string { + if data == nil { + return "" + } + queryField, exists := data["query"] + if !exists { + return "" + } + queryStr, ok := queryField.(string) + if !ok { + return "" + } + return queryStr +} + +// looksLikeGraphQLQuery checks if the query string looks like a GraphQL query +// Every GraphQL query should have at least curly braces +func looksLikeGraphQLQuery(query string) bool { + return strings.Contains(query, "{") && strings.Contains(query, "}") +} + +// ExtractInputsFromGraphQL extracts user inputs from a GraphQL request +// This includes: +// - String values from the GraphQL document AST +// - Variable values (strings only) +func ExtractInputsFromGraphQL( + body map[string]interface{}, + query map[string]interface{}, + method string, +) map[string]string { + result := make(map[string]string) + + var queryString string + var variables map[string]interface{} + + // Extract query and variables based on method + if method == "POST" && body != nil { + queryString = extractQueryString(body) + // We don't extract variables from body, because they are already in sources (body.variables) + } else if method == "GET" && query != nil { + queryString = extractQueryString(query) + if varsField, exists := query["variables"]; exists { + // Variables in GET requests might be JSON-encoded strings + if varsStr, ok := varsField.(string); ok { + var varsMap map[string]interface{} + if err := json.Unmarshal([]byte(varsStr), &varsMap); err == nil { + variables = varsMap + } + } else if varsMap, ok := varsField.(map[string]interface{}); ok { + variables = varsMap + } + } + } + + // Parse GraphQL document and extract string values + if queryString != "" { + inputs := extractStringValuesFromDocument(queryString) + for _, input := range inputs { + result[input] = ".query" + } + } + + // Extract string values from variables + if variables != nil { + varInputs := helpers.ExtractStringsFromUserInput(variables, []helpers.PathPart{{Type: "object", Key: "graphql.variables"}}, 0) + for k, v := range varInputs { + result[k] = ".variables" + v + } + } + + return result +} + +const maxGraphQLRecursionDepth = 200 + +// extractStringValuesFromDocument parses a GraphQL document and extracts all string values +// This is similar to the Node.js implementation using visit() +func extractStringValuesFromDocument(queryString string) []string { + var inputs []string + + // Parse the GraphQL document + src := source.NewSource(&source.Source{ + Body: []byte(queryString), + Name: "GraphQL request", + }) + + doc, err := parser.Parse(parser.ParseParams{Source: src}) + if err != nil { + log.Warnf("Failed to parse GraphQL document: %v", err) + return inputs + } + + // Recursively visit all nodes in the AST and extract string values + // Start with depth 0 + // Walk AST and collect string values + // Walk AST with depth tracking + depth := 0 + visitor.Visit(doc, &visitor.VisitorOptions{ + Enter: func(p visitor.VisitFuncParams) (string, interface{}) { + depth++ + if depth > maxGraphQLRecursionDepth { + return visitor.ActionSkip, nil + } + + if node, ok := p.Node.(*ast.StringValue); ok { + inputs = append(inputs, node.Value) + } + + return visitor.ActionNoChange, nil + }, + Leave: func(p visitor.VisitFuncParams) (string, interface{}) { + depth-- + return visitor.ActionNoChange, nil + }, + }, nil) + + return inputs +} + +// ExtractTopLevelFields extracts the top-level fields from a GraphQL document +// Returns the operation type (query/mutation) and field names +func ExtractTopLevelFields(queryString string, operationName string) (operationType string, fields []string) { + if queryString == "" { + return "", nil + } + + // Parse the GraphQL document + src := source.NewSource(&source.Source{ + Body: []byte(queryString), + Name: "GraphQL request", + }) + + doc, err := parser.Parse(parser.ParseParams{Source: src}) + if err != nil { + log.Warnf("Failed to parse GraphQL document: %v", err) + return "", nil + } + + // Find the operation definition + var operation *ast.OperationDefinition + for _, def := range doc.Definitions { + if opDef, ok := def.(*ast.OperationDefinition); ok { + // If no operation name is specified and there's only one operation, use it + if operationName == "" && len(doc.Definitions) == 1 { + operation = opDef + break + } + // If operation name is specified, find the matching operation + if operationName != "" && opDef.Name != nil && opDef.Name.Value == operationName { + operation = opDef + break + } + // If no operation name and multiple operations, use the first one (not ideal but matches Node.js behavior) + if operation == nil { + operation = opDef + } + } + } + + if operation == nil { + return "", nil + } + + // Extract operation type + operationType = operation.Operation + + // Extract top-level field names + if operation.SelectionSet != nil { + for _, selection := range operation.SelectionSet.Selections { + if field, ok := selection.(*ast.Field); ok { + if field.Name != nil { + fields = append(fields, field.Name.Value) + } + } + } + } + + return operationType, fields +} diff --git a/lib/request-processor/utils/graphql_test.go b/lib/request-processor/utils/graphql_test.go new file mode 100644 index 000000000..622ad9df8 --- /dev/null +++ b/lib/request-processor/utils/graphql_test.go @@ -0,0 +1,249 @@ +package utils + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsGraphQLOverHTTP_POST(t *testing.T) { + // Test POST request with valid GraphQL query + body := map[string]interface{}{ + "query": "{ user(id: \"123\") { id name } }", + } + result := IsGraphQLOverHTTP("POST", "/graphql", "application/json", body, nil) + assert.True(t, result, "Should detect valid GraphQL POST request") + + // Test with application/graphql content type + result = IsGraphQLOverHTTP("POST", "/graphql", "application/graphql", body, nil) + assert.True(t, result, "Should detect GraphQL with application/graphql content type") + + // Test without JSON content type + result = IsGraphQLOverHTTP("POST", "/graphql", "text/plain", body, nil) + assert.False(t, result, "Should not detect GraphQL without JSON content type") + + // Test without /graphql route + result = IsGraphQLOverHTTP("POST", "/api/data", "application/json", body, nil) + assert.False(t, result, "Should not detect GraphQL without /graphql route") + + // Test without query field + bodyWithoutQuery := map[string]interface{}{ + "data": "something", + } + result = IsGraphQLOverHTTP("POST", "/graphql", "application/json", bodyWithoutQuery, nil) + assert.False(t, result, "Should not detect GraphQL without query field") + + // Test with non-string query + bodyWithNonStringQuery := map[string]interface{}{ + "query": 123, + } + result = IsGraphQLOverHTTP("POST", "/graphql", "application/json", bodyWithNonStringQuery, nil) + assert.False(t, result, "Should not detect GraphQL with non-string query") + + // Test with query that doesn't look like GraphQL + bodyWithBadQuery := map[string]interface{}{ + "query": "SELECT * FROM users", + } + result = IsGraphQLOverHTTP("POST", "/graphql", "application/json", bodyWithBadQuery, nil) + assert.False(t, result, "Should not detect GraphQL with SQL query") +} + +func TestIsGraphQLOverHTTP_GET(t *testing.T) { + // Test GET request with valid GraphQL query + query := map[string]interface{}{ + "query": "{ user(id: \"123\") { id name } }", + } + result := IsGraphQLOverHTTP("GET", "/graphql", "", nil, query) + assert.True(t, result, "Should detect valid GraphQL GET request") + + // Test without /graphql route + result = IsGraphQLOverHTTP("GET", "/api/data", "", nil, query) + assert.False(t, result, "Should not detect GraphQL without /graphql route") + + // Test without query parameter + emptyQuery := map[string]interface{}{} + result = IsGraphQLOverHTTP("GET", "/graphql", "", nil, emptyQuery) + assert.False(t, result, "Should not detect GraphQL without query parameter") +} + +func TestLooksLikeGraphQLQuery(t *testing.T) { + assert.True(t, looksLikeGraphQLQuery("{ user { id } }")) + assert.True(t, looksLikeGraphQLQuery("query { user { id } }")) + assert.True(t, looksLikeGraphQLQuery("mutation { createUser { id } }")) + assert.False(t, looksLikeGraphQLQuery("SELECT * FROM users")) + assert.False(t, looksLikeGraphQLQuery("plain text")) + assert.False(t, looksLikeGraphQLQuery("")) +} + +func TestExtractInputsFromGraphQL_POST(t *testing.T) { + // Test extracting inputs from query document + body := map[string]interface{}{ + "query": `query { user(id: "123", name: "John") { id name } }`, + } + result := ExtractInputsFromGraphQL(body, nil, "POST") + + // Should extract string literals from query + assert.Contains(t, result, "123") + assert.Contains(t, result, "John") + + // "query": "mutation { uploadFile(url: \"http://localhost/secrets\") { success } }", + bodyWithMutation := map[string]interface{}{ + "query": "mutation { uploadFile(url: \"http://localhost/secrets\") { success } }", + } + result = ExtractInputsFromGraphQL(bodyWithMutation, nil, "POST") + assert.Contains(t, result, "http://localhost/secrets") + + // Test with variables + bodyWithVariablesMutation := map[string]interface{}{ + "query": "mutation { uploadFile(url: \"http://localhost2/secrets\") { success } }", + } + result = ExtractInputsFromGraphQL(bodyWithVariablesMutation, nil, "POST") + assert.Contains(t, result, "http://localhost2/secrets") +} + +func TestExtractInputsFromGraphQL_GET(t *testing.T) { + // Test GET request with query in query parameters + query := map[string]interface{}{ + "query": `{ user(id: "789") { id name } }`, + } + result := ExtractInputsFromGraphQL(nil, query, "GET") + + assert.Contains(t, result, "789") + + // Test with JSON-encoded variables + queryWithVariables := map[string]interface{}{ + "query": `query GetUser($id: ID!) { user(id: $id) { id } }`, + "variables": `{"id": "999", "name": "Test"}`, + } + result = ExtractInputsFromGraphQL(nil, queryWithVariables, "GET") + + assert.Contains(t, result, "999") + assert.Contains(t, result, "Test") +} + +func TestExtractStringValuesFromDocument(t *testing.T) { + // Test simple query + query := `{ user(id: "123") { id name } }` + inputs := extractStringValuesFromDocument(query) + assert.Equal(t, 1, len(inputs)) + assert.Contains(t, inputs, "123") + + // Test query with multiple string values + query = `{ user(id: "123", email: "test@example.com") { id name address(city: "NYC") } }` + inputs = extractStringValuesFromDocument(query) + assert.Equal(t, 3, len(inputs)) + assert.Contains(t, inputs, "123") + assert.Contains(t, inputs, "test@example.com") + assert.Contains(t, inputs, "NYC") + + // Test mutation + mutation := `mutation { createUser(name: "John", email: "john@example.com") { id } }` + inputs = extractStringValuesFromDocument(mutation) + assert.Equal(t, 2, len(inputs)) + assert.Contains(t, inputs, "John") + assert.Contains(t, inputs, "john@example.com") + + // Test with invalid query (should not crash) + invalidQuery := `this is not valid GraphQL` + inputs = extractStringValuesFromDocument(invalidQuery) + assert.Equal(t, 0, len(inputs)) +} + +func TestExtractTopLevelFields(t *testing.T) { + // Test query operation + query := `query { user { id } posts { title } }` + opType, fields := ExtractTopLevelFields(query, "") + assert.Equal(t, "query", opType) + assert.Equal(t, 2, len(fields)) + assert.Contains(t, fields, "user") + assert.Contains(t, fields, "posts") + + // Test mutation operation + mutation := `mutation { createUser { id } deletePost { success } }` + opType, fields = ExtractTopLevelFields(mutation, "") + assert.Equal(t, "mutation", opType) + assert.Equal(t, 2, len(fields)) + assert.Contains(t, fields, "createUser") + assert.Contains(t, fields, "deletePost") + + // Test with operation name + queryWithName := `query GetUser { user { id } }` + opType, fields = ExtractTopLevelFields(queryWithName, "GetUser") + assert.Equal(t, "query", opType) + assert.Equal(t, 1, len(fields)) + assert.Contains(t, fields, "user") + + // Test with invalid query + invalidQuery := `this is not valid` + opType, fields = ExtractTopLevelFields(invalidQuery, "") + assert.Equal(t, "", opType) + assert.Nil(t, fields) + + // Test with empty query + opType, fields = ExtractTopLevelFields("", "") + assert.Equal(t, "", opType) + assert.Nil(t, fields) +} + +func TestIsGraphQLRoute(t *testing.T) { + // Standard patterns + assert.True(t, isGraphQLRoute("/graphql")) + assert.True(t, isGraphQLRoute("/api/graphql")) + assert.True(t, isGraphQLRoute("/v1/graphql")) + + // GraphQL in the middle of path + assert.True(t, isGraphQLRoute("/graphql/api")) + assert.True(t, isGraphQLRoute("/index.php?p=admin/actions/graphql/api")) + + // Case insensitive + assert.True(t, isGraphQLRoute("/GraphQL")) + assert.True(t, isGraphQLRoute("/api/GRAPHQL")) + + // Should NOT match + assert.False(t, isGraphQLRoute("/api/users")) + assert.False(t, isGraphQLRoute("")) +} + +func TestIsJSONContentType(t *testing.T) { + assert.True(t, isJSONContentType("application/json")) + assert.True(t, isJSONContentType("application/json; charset=utf-8")) + assert.True(t, isJSONContentType("Application/JSON")) + assert.True(t, isJSONContentType("application/graphql")) + assert.False(t, isJSONContentType("text/plain")) + assert.False(t, isJSONContentType("application/xml")) + assert.False(t, isJSONContentType("")) +} + +func TestExtractStringValuesFromDocument_DeeplyNested(t *testing.T) { + // Test with a deeply nested query to ensure recursion limit works + // Without a limit, extremely nested queries could cause stack overflow + query := `{ user(id: "level0") {` + + // Add many nested levels (well beyond what's reasonable) + for i := 1; i <= 150; i++ { + query += ` friends { user(id: "level` + strconv.Itoa(i) + `") {` + } + + // Close with a field selection + query += ` id` + + // Close all braces + for i := 0; i <= 150; i++ { + query += ` }}` + } + + // Should not crash - the recursion limit protects against stack overflow + inputs := extractStringValuesFromDocument(query) + + // Should extract values from at least the first levels before hitting the limit + assert.NotEmpty(t, inputs, "Should extract values from nested query") + assert.Contains(t, inputs, "level0", "Should extract value from first level") + assert.Contains(t, inputs, "level10", "Should extract value from early levels") + + // Should NOT extract all 150 levels - the recursion limit should stop it + assert.Less(t, len(inputs), 150, "Recursion limit should prevent extracting all levels") + + // But should extract a reasonable number before hitting the limit + assert.Greater(t, len(inputs), 10, "Should extract values before hitting limit") +} diff --git a/tests/cli/graphql/test_graphql_get_query.phpt b/tests/cli/graphql/test_graphql_get_query.phpt new file mode 100644 index 000000000..7707e548d --- /dev/null +++ b/tests/cli/graphql/test_graphql_get_query.phpt @@ -0,0 +1,29 @@ +--TEST-- +Test traversal attack detection in GraphQL GET query + +--ENV-- +AIKIDO_LOG_LEVEL=DEBUG +AIKIDO_BLOCK=1 +REQUEST_URI=/graphql +HTTP_HOST=test.local +REQUEST_METHOD=POST + + +--GET-- +query=query+GetUsers+%7B%0A++users%28limit%3A+5%2C+offset%3A+0%2C+path%3A+%22..%2F..%2Fetc%2Fpasswd%22%29+%7B%0A++++id%0A++++name%0A++++email%0A++++posts+%7B%0A++++++id%0A++++++title%0A++++++createdAt%0A++++%7D%0A++%7D%0A%7D&variables=%7B%22limit%22%3A5%2C%22offset%22%3A0%7D +--FILE-- +getMessage(); +} +?> + +--EXPECTREGEX-- +.*Detected GraphQL request.* +.*Aikido firewall has blocked a path traversal attack: file_get_contents\(...\) originating from graphql.* + + diff --git a/tests/cli/graphql/test_graphql_non_graphql_route.phpt b/tests/cli/graphql/test_graphql_non_graphql_route.phpt new file mode 100644 index 000000000..baa96f9cf --- /dev/null +++ b/tests/cli/graphql/test_graphql_non_graphql_route.phpt @@ -0,0 +1,29 @@ +--TEST-- +Test that GraphQL-like requests to non-GraphQL routes are not detected + +--ENV-- +AIKIDO_LOG_LEVEL=DEBUG +REQUEST_URI=/api/data +HTTP_HOST=test.local +REQUEST_METHOD=POST +AIKIDO_BLOCK=1 + +--POST_RAW-- +Content-Type: application/json + +{ + "query": "query { user(id: \"123\") { name } }" +} + +--FILE-- + + +--EXPECTREGEX-- +(?s)\A(?!.*Detected GraphQL request).*?\z + diff --git a/tests/cli/graphql/test_graphql_sql_injection.phpt b/tests/cli/graphql/test_graphql_sql_injection.phpt new file mode 100644 index 000000000..f61290063 --- /dev/null +++ b/tests/cli/graphql/test_graphql_sql_injection.phpt @@ -0,0 +1,48 @@ +--TEST-- +Test SQL injection detection in GraphQL variables + +--ENV-- +AIKIDO_LOG_LEVEL=DEBUG +AIKIDO_BLOCK=1 +REQUEST_URI=/graphql +HTTP_HOST=test.local +REQUEST_METHOD=POST + + +--POST_RAW-- +Content-Type: application/json + +{ + "query": "query GetUser($userId: String!) { user(id: \"1' OR '1'='1\") { name } }" +} + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $pdo->exec("CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + )"); + + $userId = "1' OR '1'='1"; + + // Vulnerable SQL query + $query = "SELECT * FROM users WHERE id = '" . $userId . "'"; + $stmt = $pdo->prepare($query); + $stmt->execute(); + + echo "Query executed!"; + +} catch (PDOException $e) { + echo "Error: " . $e->getMessage(); +} + +?> + +--EXPECTREGEX-- +.*Detected GraphQL request.* +.*Aikido firewall has blocked an SQL injection.* + diff --git a/tests/cli/graphql/test_graphql_ssrf_nested_mutation.phpt b/tests/cli/graphql/test_graphql_ssrf_nested_mutation.phpt new file mode 100644 index 000000000..b4d205cf9 --- /dev/null +++ b/tests/cli/graphql/test_graphql_ssrf_nested_mutation.phpt @@ -0,0 +1,43 @@ +--TEST-- +Test SSRF detection in nested GraphQL mutation with private IP + +--ENV-- +AIKIDO_LOG_LEVEL=DEBUG +AIKIDO_BLOCK=1 +REQUEST_URI=/graphql +HTTP_HOST=test.local +REQUEST_METHOD=POST + + +--POST_RAW-- +Content-Type: application/json + +{"query":"mutation {\n save_testVol_Asset(_file: { \n url: \"http://0.0.0.0:80\"\n filename: \"poc.txt\"\n }) {\n id\n }\n}"} + +--FILE-- + + +--EXPECTREGEX-- +.*Detected GraphQL request.* +.*Aikido firewall has blocked a server-side request forgery.* +