From e0980650c6fc6ee3cbd9e57322690b2555c0a6c0 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 19 Jan 2026 13:55:03 +0000 Subject: [PATCH 1/9] Adds detection and protection for GraphQL endpoints. The firewall now recognizes GraphQL requests and scans inputs for attacks. --- lib/request-processor/context/cache.go | 45 +++ lib/request-processor/context/data_sources.go | 1 + .../context/request_context.go | 10 + lib/request-processor/go.mod | 11 +- lib/request-processor/go.sum | 102 ++---- lib/request-processor/utils/graphql.go | 320 ++++++++++++++++++ lib/request-processor/utils/graphql_test.go | 225 ++++++++++++ .../test_graphql_non_graphql_route.phpt | 29 ++ ...t_graphql_path_traversal_in_variables.phpt | 38 +++ ..._graphql_shell_injection_in_variables.phpt | 38 +++ ...st_graphql_sql_injection_in_variables.phpt | 54 +++ .../test_graphql_ssrf_nested_mutation.phpt | 43 +++ .../test_graphql_ssrf_with_variables.phpt | 50 +++ 13 files changed, 889 insertions(+), 77 deletions(-) create mode 100644 lib/request-processor/utils/graphql.go create mode 100644 lib/request-processor/utils/graphql_test.go create mode 100644 tests/cli/graphql/test_graphql_non_graphql_route.phpt create mode 100644 tests/cli/graphql/test_graphql_path_traversal_in_variables.phpt create mode 100644 tests/cli/graphql/test_graphql_shell_injection_in_variables.phpt create mode 100644 tests/cli/graphql/test_graphql_sql_injection_in_variables.phpt create mode 100644 tests/cli/graphql/test_graphql_ssrf_nested_mutation.phpt create mode 100644 tests/cli/graphql/test_graphql_ssrf_with_variables.phpt diff --git a/lib/request-processor/context/cache.go b/lib/request-processor/context/cache.go index 5eb5e4d2f..97491840e 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,47 @@ func ContextSetIsEndpointIpAllowed() { func ContextSetIsEndpointRateLimited() { Context.IsEndpointRateLimited = true } + +func ContextSetGraphQL() { + if Context.GraphQLParsedFlattened != nil { + return + } + + // Check if this is a GraphQL request + method := GetMethod() + url := GetUrl() + + // Get content-type from headers + var contentType string + headers := GetHeadersParsed() + contentType, ok := headers["content_type"].(string) + if !ok { + isGraphQL := false + Context.IsGraphQLRequest = &isGraphQL + emptyMap := make(map[string]string) + Context.GraphQLParsedFlattened = &emptyMap + return + } + contentType = strings.ToLower(strings.TrimSpace(contentType)) + + body := GetBodyParsed() + query := GetQueryParsed() + + isGraphQL := utils.IsGraphQLOverHTTP(method, url, contentType, body, query) + Context.IsGraphQLRequest = &isGraphQL + + if !isGraphQL { + // Not a GraphQL request, return empty map + emptyMap := make(map[string]string) + Context.GraphQLParsedFlattened = &emptyMap + return + } + + log.Debug("Detected GraphQL request") + + // Extract GraphQL inputs + graphqlInputs := utils.ExtractInputsFromGraphQL(body, query, method) + Context.GraphQLParsedFlattened = &graphqlInputs + + log.Debugf("Extracted %d GraphQL inputs", len(graphqlInputs)) +} 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..29277374d 100644 --- a/lib/request-processor/context/request_context.go +++ b/lib/request-processor/context/request_context.go @@ -42,6 +42,8 @@ type RequestContextData struct { RouteParamsRaw *string RouteParamsParsed *map[string]interface{} RouteParamsParsedFlattened *map[string]string + GraphQLParsedFlattened *map[string]string + IsGraphQLRequest *bool } var Context RequestContextData @@ -141,6 +143,14 @@ func GetHeadersParsedFlattened() map[string]string { return GetFromCache(ContextSetHeaders, &Context.HeadersParsedFlattened) } +func GetGraphQLParsedFlattened() map[string]string { + return GetFromCache(ContextSetGraphQL, &Context.GraphQLParsedFlattened) +} + +func IsGraphQLRequest() bool { + return GetFromCache(ContextSetGraphQL, &Context.IsGraphQLRequest) +} + 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..d619e5d3d --- /dev/null +++ b/lib/request-processor/utils/graphql.go @@ -0,0 +1,320 @@ +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" +) + +// IsGraphQLOverHTTP checks if the current request is a GraphQL over HTTP request +// Similar to firewall-node/library/sources/graphql/isGraphQLOverHTTP.ts +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(getQueryString(body)) + } + + if method == "GET" { + queryStr := getQueryStringFromQueryParams(query) + return isGraphQLRoute(url) && + queryStr != "" && + looksLikeGraphQLQuery(queryStr) + } + + return false +} + +// isGraphQLRoute checks if the URL ends with /graphql +func isGraphQLRoute(url string) bool { + if url == "" { + return false + } + return strings.HasSuffix(url, "/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 +} + +// getQueryString extracts the query string from the body +func getQueryString(body map[string]interface{}) string { + if body == nil { + return "" + } + queryField, exists := body["query"] + if !exists { + return "" + } + queryStr, ok := queryField.(string) + if !ok { + return "" + } + return queryStr +} + +// getQueryStringFromQueryParams extracts the query string from query parameters +func getQueryStringFromQueryParams(query map[string]interface{}) string { + if query == nil { + return "" + } + queryField, exists := query["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) +// Similar to firewall-node/library/sources/graphql/extractInputsFromDocument.ts +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 = getQueryString(body) + if varsField, exists := body["variables"]; exists { + if varsMap, ok := varsField.(map[string]interface{}); ok { + variables = varsMap + } + } + } else if method == "GET" && query != nil { + queryString = getQueryStringFromQueryParams(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] = ".graphql.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] = ".graphql.variables" + v + } + } + + return result +} + +// 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 + visitNode(doc, &inputs) + + return inputs +} + +// visitNode recursively visits AST nodes and extracts string values +func visitNode(node interface{}, inputs *[]string) { + if node == nil { + return + } + + switch n := node.(type) { + case *ast.Document: + for _, def := range n.Definitions { + visitNode(def, inputs) + } + case *ast.OperationDefinition: + if n.SelectionSet != nil { + visitNode(n.SelectionSet, inputs) + } + for _, varDef := range n.VariableDefinitions { + visitNode(varDef, inputs) + } + case *ast.VariableDefinition: + if n.DefaultValue != nil { + visitNode(n.DefaultValue, inputs) + } + case *ast.SelectionSet: + for _, sel := range n.Selections { + visitNode(sel, inputs) + } + case *ast.Field: + for _, arg := range n.Arguments { + visitNode(arg, inputs) + } + if n.SelectionSet != nil { + visitNode(n.SelectionSet, inputs) + } + case *ast.Argument: + visitNode(n.Value, inputs) + case *ast.StringValue: + *inputs = append(*inputs, n.Value) + case *ast.IntValue: + // Skip int values + case *ast.FloatValue: + // Skip float values + case *ast.BooleanValue: + // Skip boolean values + case *ast.EnumValue: + // Skip enum values + case *ast.ListValue: + for _, val := range n.Values { + visitNode(val, inputs) + } + case *ast.ObjectValue: + for _, field := range n.Fields { + visitNode(field, inputs) + } + case *ast.ObjectField: + visitNode(n.Value, inputs) + case *ast.Variable: + // Skip variables (they are handled separately) + case *ast.FragmentDefinition: + if n.SelectionSet != nil { + visitNode(n.SelectionSet, inputs) + } + case *ast.InlineFragment: + if n.SelectionSet != nil { + visitNode(n.SelectionSet, inputs) + } + case *ast.FragmentSpread: + // Skip fragment spreads + } +} + +// ExtractTopLevelFields extracts the top-level fields from a GraphQL document +// Returns the operation type (query/mutation) and field names +// Similar to firewall-node/library/sources/graphql/extractTopLevelFieldsFromDocument.ts +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..6e2edbbc3 --- /dev/null +++ b/lib/request-processor/utils/graphql_test.go @@ -0,0 +1,225 @@ +package utils + +import ( + "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") + + // Test with variables + bodyWithVariables := map[string]interface{}{ + "query": `query GetUser($id: ID!) { user(id: $id) { id name } }`, + "variables": map[string]interface{}{ + "id": "456", + "name": "Jane", + "age": 30, // Non-string should be handled + "email": "jane@example.com", + }, + } + result = ExtractInputsFromGraphQL(bodyWithVariables, nil, "POST") + + // Should extract string variables + assert.Contains(t, result, "456") + assert.Contains(t, result, "Jane") + assert.Contains(t, result, "jane@example.com") + + // "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://localhost/secrets\") { success } }", + "variables": map[string]interface{}{ + "url": "http://localhost/secrets", + }, + } + result = ExtractInputsFromGraphQL(bodyWithVariablesMutation, nil, "POST") + assert.Contains(t, result, "http://localhost/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) { + assert.True(t, isGraphQLRoute("/graphql")) + assert.True(t, isGraphQLRoute("/api/graphql")) + assert.True(t, isGraphQLRoute("/v1/graphql")) + assert.False(t, isGraphQLRoute("/api/users")) + assert.False(t, isGraphQLRoute("/graphql/schema")) + 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("")) +} 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_path_traversal_in_variables.phpt b/tests/cli/graphql/test_graphql_path_traversal_in_variables.phpt new file mode 100644 index 000000000..47159ec93 --- /dev/null +++ b/tests/cli/graphql/test_graphql_path_traversal_in_variables.phpt @@ -0,0 +1,38 @@ +--TEST-- +Test path traversal 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 GetFile($path: String!) { file(path: $path) { content } }", + "variables": { + "path": "../../etc/passwd" + } +} + +--FILE-- + + +--EXPECTREGEX-- +.*Detected GraphQL request.* +.*Aikido firewall has blocked a path traversal attack.* + diff --git a/tests/cli/graphql/test_graphql_shell_injection_in_variables.phpt b/tests/cli/graphql/test_graphql_shell_injection_in_variables.phpt new file mode 100644 index 000000000..fdc3b74a4 --- /dev/null +++ b/tests/cli/graphql/test_graphql_shell_injection_in_variables.phpt @@ -0,0 +1,38 @@ +--TEST-- +Test shell 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": "mutation ExportData($filename: String!) { export(filename: $filename) { success } }", + "variables": { + "filename": "data.txt; cat /etc/passwd" + } +} + +--FILE-- + + +--EXPECTREGEX-- +.*Detected GraphQL request.* +.*Aikido firewall has blocked a shell injection.* + diff --git a/tests/cli/graphql/test_graphql_sql_injection_in_variables.phpt b/tests/cli/graphql/test_graphql_sql_injection_in_variables.phpt new file mode 100644 index 000000000..9ce4e35c2 --- /dev/null +++ b/tests/cli/graphql/test_graphql_sql_injection_in_variables.phpt @@ -0,0 +1,54 @@ +--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: $userId) { name } }", + "variables": { + "userId": "1' OR '1'='1" + } +} + +--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 + )"); + + $input = file_get_contents('php://input'); + $data = json_decode($input, true); + + $userId = $data['variables']['userId'] ?? ''; + + // 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.* + diff --git a/tests/cli/graphql/test_graphql_ssrf_with_variables.phpt b/tests/cli/graphql/test_graphql_ssrf_with_variables.phpt new file mode 100644 index 000000000..21cfe5f78 --- /dev/null +++ b/tests/cli/graphql/test_graphql_ssrf_with_variables.phpt @@ -0,0 +1,50 @@ +--TEST-- +Test SSRF detection in GraphQL with variables containing 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 SaveAsset($file: FileInput!) { save_asset(file: $file) { id } }", + "variables": { + "file": { + "url": "http://127.0.0.1:8080/admin", + "filename": "malicious.txt" + } + } +} + +--FILE-- + + +--EXPECTREGEX-- +.*Detected GraphQL request.* +.*Aikido firewall has blocked a server-side request forgery.* + From 4ac9ef4b43d088b2270e6cf06810d9f889ae7b6d Mon Sep 17 00:00:00 2001 From: root Date: Mon, 19 Jan 2026 14:29:36 +0000 Subject: [PATCH 2/9] add recursion depth limit to prevent stack overflow --- lib/request-processor/utils/graphql.go | 43 +++++++++++++-------- lib/request-processor/utils/graphql_test.go | 34 ++++++++++++++++ 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/lib/request-processor/utils/graphql.go b/lib/request-processor/utils/graphql.go index d619e5d3d..033029648 100644 --- a/lib/request-processor/utils/graphql.go +++ b/lib/request-processor/utils/graphql.go @@ -181,46 +181,58 @@ func extractStringValuesFromDocument(queryString string) []string { } // Recursively visit all nodes in the AST and extract string values - visitNode(doc, &inputs) + // Start with depth 0 + visitNode(doc, &inputs, 0) return inputs } +const maxGraphQLRecursionDepth = 100 + // visitNode recursively visits AST nodes and extracts string values -func visitNode(node interface{}, inputs *[]string) { +// depth tracking prevents stack overflow from malicious deeply nested queries +func visitNode(node interface{}, inputs *[]string, depth int) { if node == nil { return } + // Prevent excessive recursion + if depth > maxGraphQLRecursionDepth { + log.Warnf("GraphQL query exceeds maximum recursion depth of %d", maxGraphQLRecursionDepth) + return + } + + nextDepth := depth + 1 + switch n := node.(type) { case *ast.Document: for _, def := range n.Definitions { - visitNode(def, inputs) + visitNode(def, inputs, nextDepth) } case *ast.OperationDefinition: if n.SelectionSet != nil { - visitNode(n.SelectionSet, inputs) + visitNode(n.SelectionSet, inputs, nextDepth) } for _, varDef := range n.VariableDefinitions { - visitNode(varDef, inputs) + visitNode(varDef, inputs, nextDepth) } case *ast.VariableDefinition: if n.DefaultValue != nil { - visitNode(n.DefaultValue, inputs) + visitNode(n.DefaultValue, inputs, nextDepth) } case *ast.SelectionSet: for _, sel := range n.Selections { - visitNode(sel, inputs) + visitNode(sel, inputs, nextDepth) } case *ast.Field: for _, arg := range n.Arguments { - visitNode(arg, inputs) + visitNode(arg, inputs, nextDepth) } if n.SelectionSet != nil { - visitNode(n.SelectionSet, inputs) + visitNode(n.SelectionSet, inputs, nextDepth) } case *ast.Argument: - visitNode(n.Value, inputs) + visitNode(n.Value, inputs, nextDepth) case *ast.StringValue: *inputs = append(*inputs, n.Value) case *ast.IntValue: @@ -233,23 +245,23 @@ func visitNode(node interface{}, inputs *[]string) { // Skip enum values case *ast.ListValue: for _, val := range n.Values { - visitNode(val, inputs) + visitNode(val, inputs, nextDepth) } case *ast.ObjectValue: for _, field := range n.Fields { - visitNode(field, inputs) + visitNode(field, inputs, nextDepth) } case *ast.ObjectField: - visitNode(n.Value, inputs) + visitNode(n.Value, inputs, nextDepth) case *ast.Variable: // Skip variables (they are handled separately) case *ast.FragmentDefinition: if n.SelectionSet != nil { - visitNode(n.SelectionSet, inputs) + visitNode(n.SelectionSet, inputs, nextDepth) } case *ast.InlineFragment: if n.SelectionSet != nil { - visitNode(n.SelectionSet, inputs) + visitNode(n.SelectionSet, inputs, nextDepth) } case *ast.FragmentSpread: // Skip fragment spreads @@ -317,4 +329,3 @@ func ExtractTopLevelFields(queryString string, operationName string) (operationT return operationType, fields } - diff --git a/lib/request-processor/utils/graphql_test.go b/lib/request-processor/utils/graphql_test.go index 6e2edbbc3..f8a33a80e 100644 --- a/lib/request-processor/utils/graphql_test.go +++ b/lib/request-processor/utils/graphql_test.go @@ -1,6 +1,7 @@ package utils import ( + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -223,3 +224,36 @@ func TestIsJSONContentType(t *testing.T) { 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") +} From 0a743c3f3bd355dde83a82e215851d26b8fa55a3 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 19 Jan 2026 15:39:36 +0000 Subject: [PATCH 3/9] Enhance GraphQL route detection to support various URL patterns --- lib/request-processor/utils/graphql.go | 6 ++++-- lib/request-processor/utils/graphql_test.go | 12 +++++++++++- .../graphql/test_graphql_ssrf_with_variables.phpt | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/request-processor/utils/graphql.go b/lib/request-processor/utils/graphql.go index 033029648..e33d2a697 100644 --- a/lib/request-processor/utils/graphql.go +++ b/lib/request-processor/utils/graphql.go @@ -37,12 +37,14 @@ func IsGraphQLOverHTTP( return false } -// isGraphQLRoute checks if the URL ends with /graphql +// 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 } - return strings.HasSuffix(url, "/graphql") + urlLower := strings.ToLower(url) + return strings.Contains(urlLower, "/graphql") || strings.Contains(urlLower, "graphql/") } // isJSONContentType checks if the content type is JSON diff --git a/lib/request-processor/utils/graphql_test.go b/lib/request-processor/utils/graphql_test.go index f8a33a80e..101a607c6 100644 --- a/lib/request-processor/utils/graphql_test.go +++ b/lib/request-processor/utils/graphql_test.go @@ -207,11 +207,21 @@ func TestExtractTopLevelFields(t *testing.T) { } 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("/graphql/schema")) assert.False(t, isGraphQLRoute("")) } diff --git a/tests/cli/graphql/test_graphql_ssrf_with_variables.phpt b/tests/cli/graphql/test_graphql_ssrf_with_variables.phpt index 21cfe5f78..3a8b2b59c 100644 --- a/tests/cli/graphql/test_graphql_ssrf_with_variables.phpt +++ b/tests/cli/graphql/test_graphql_ssrf_with_variables.phpt @@ -4,7 +4,7 @@ Test SSRF detection in GraphQL with variables containing private IP --ENV-- AIKIDO_LOG_LEVEL=DEBUG AIKIDO_BLOCK=1 -REQUEST_URI=/graphql +REQUEST_URI=/index.php?p=admin/actions/graphql/api HTTP_HOST=test.local REQUEST_METHOD=POST From 85e9504d83ce270eaf54f370332c8ad8b250f0c6 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 23 Jan 2026 10:09:40 +0000 Subject: [PATCH 4/9] refactor --- lib/request-processor/context/cache.go | 46 ++++++++++++-------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/lib/request-processor/context/cache.go b/lib/request-processor/context/cache.go index 97491840e..7f2faa911 100644 --- a/lib/request-processor/context/cache.go +++ b/lib/request-processor/context/cache.go @@ -255,6 +255,10 @@ func ContextSetGraphQL() { return } + // Default to not a GraphQL request with empty inputs + isGraphQL := false + emptyMap := make(map[string]string) + // Check if this is a GraphQL request method := GetMethod() url := GetUrl() @@ -263,33 +267,27 @@ func ContextSetGraphQL() { var contentType string headers := GetHeadersParsed() contentType, ok := headers["content_type"].(string) - if !ok { - isGraphQL := false - Context.IsGraphQLRequest = &isGraphQL - emptyMap := make(map[string]string) - Context.GraphQLParsedFlattened = &emptyMap - return - } - contentType = strings.ToLower(strings.TrimSpace(contentType)) + if ok { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + body := GetBodyParsed() + query := GetQueryParsed() - body := GetBodyParsed() - query := GetQueryParsed() - - isGraphQL := utils.IsGraphQLOverHTTP(method, url, contentType, body, query) - Context.IsGraphQLRequest = &isGraphQL + isGraphQL = utils.IsGraphQLOverHTTP(method, url, contentType, body, query) - if !isGraphQL { - // Not a GraphQL request, return empty map - emptyMap := make(map[string]string) - Context.GraphQLParsedFlattened = &emptyMap - return - } + if isGraphQL { + log.Debug("Detected GraphQL request") - log.Debug("Detected GraphQL request") + // Extract GraphQL inputs + graphqlInputs := utils.ExtractInputsFromGraphQL(body, query, method) + Context.GraphQLParsedFlattened = &graphqlInputs + Context.IsGraphQLRequest = &isGraphQL - // Extract GraphQL inputs - graphqlInputs := utils.ExtractInputsFromGraphQL(body, query, method) - Context.GraphQLParsedFlattened = &graphqlInputs + log.Debugf("Extracted %d GraphQL inputs", len(graphqlInputs)) + return + } + } - log.Debugf("Extracted %d GraphQL inputs", len(graphqlInputs)) + // Not a GraphQL request or missing content-type + Context.IsGraphQLRequest = &isGraphQL + Context.GraphQLParsedFlattened = &emptyMap } From 7796cad43cb4a2c8fde626535d278cef7d488f03 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 23 Jan 2026 11:34:04 +0000 Subject: [PATCH 5/9] Refactor GraphQL request handling --- lib/request-processor/context/cache.go | 9 ++------- lib/request-processor/context/request_context.go | 5 ----- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/lib/request-processor/context/cache.go b/lib/request-processor/context/cache.go index 7f2faa911..35af93b84 100644 --- a/lib/request-processor/context/cache.go +++ b/lib/request-processor/context/cache.go @@ -256,8 +256,7 @@ func ContextSetGraphQL() { } // Default to not a GraphQL request with empty inputs - isGraphQL := false - emptyMap := make(map[string]string) + Context.GraphQLParsedFlattened = &map[string]string{} // Check if this is a GraphQL request method := GetMethod() @@ -272,7 +271,7 @@ func ContextSetGraphQL() { body := GetBodyParsed() query := GetQueryParsed() - isGraphQL = utils.IsGraphQLOverHTTP(method, url, contentType, body, query) + isGraphQL := utils.IsGraphQLOverHTTP(method, url, contentType, body, query) if isGraphQL { log.Debug("Detected GraphQL request") @@ -280,14 +279,10 @@ func ContextSetGraphQL() { // Extract GraphQL inputs graphqlInputs := utils.ExtractInputsFromGraphQL(body, query, method) Context.GraphQLParsedFlattened = &graphqlInputs - Context.IsGraphQLRequest = &isGraphQL log.Debugf("Extracted %d GraphQL inputs", len(graphqlInputs)) return } } - // Not a GraphQL request or missing content-type - Context.IsGraphQLRequest = &isGraphQL - Context.GraphQLParsedFlattened = &emptyMap } diff --git a/lib/request-processor/context/request_context.go b/lib/request-processor/context/request_context.go index 29277374d..c858b44af 100644 --- a/lib/request-processor/context/request_context.go +++ b/lib/request-processor/context/request_context.go @@ -43,7 +43,6 @@ type RequestContextData struct { RouteParamsParsed *map[string]interface{} RouteParamsParsedFlattened *map[string]string GraphQLParsedFlattened *map[string]string - IsGraphQLRequest *bool } var Context RequestContextData @@ -147,10 +146,6 @@ func GetGraphQLParsedFlattened() map[string]string { return GetFromCache(ContextSetGraphQL, &Context.GraphQLParsedFlattened) } -func IsGraphQLRequest() bool { - return GetFromCache(ContextSetGraphQL, &Context.IsGraphQLRequest) -} - func GetUserAgent() string { return GetFromCache(ContextSetUserAgent, &Context.UserAgent) } From 61236f14d07badcc5ad858546fe40f0fdb370901 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 2 Feb 2026 18:01:25 +0000 Subject: [PATCH 6/9] Refactor GraphQL input extraction --- lib/request-processor/utils/graphql.go | 114 +++++--------------- lib/request-processor/utils/graphql_test.go | 24 +---- 2 files changed, 27 insertions(+), 111 deletions(-) diff --git a/lib/request-processor/utils/graphql.go b/lib/request-processor/utils/graphql.go index e33d2a697..2c31f547f 100644 --- a/lib/request-processor/utils/graphql.go +++ b/lib/request-processor/utils/graphql.go @@ -9,10 +9,10 @@ import ( "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 -// Similar to firewall-node/library/sources/graphql/isGraphQLOverHTTP.ts func IsGraphQLOverHTTP( method string, url string, @@ -112,7 +112,6 @@ func looksLikeGraphQLQuery(query string) bool { // This includes: // - String values from the GraphQL document AST // - Variable values (strings only) -// Similar to firewall-node/library/sources/graphql/extractInputsFromDocument.ts func ExtractInputsFromGraphQL( body map[string]interface{}, query map[string]interface{}, @@ -126,11 +125,7 @@ func ExtractInputsFromGraphQL( // Extract query and variables based on method if method == "POST" && body != nil { queryString = getQueryString(body) - if varsField, exists := body["variables"]; exists { - if varsMap, ok := varsField.(map[string]interface{}); ok { - variables = varsMap - } - } + // We don't extract variables from body, because they are already in sources (body.variables) } else if method == "GET" && query != nil { queryString = getQueryStringFromQueryParams(query) if varsField, exists := query["variables"]; exists { @@ -165,6 +160,8 @@ func ExtractInputsFromGraphQL( 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 { @@ -184,95 +181,34 @@ func extractStringValuesFromDocument(queryString string) []string { // Recursively visit all nodes in the AST and extract string values // Start with depth 0 - visitNode(doc, &inputs, 0) - - return inputs -} - -const maxGraphQLRecursionDepth = 100 - -// visitNode recursively visits AST nodes and extracts string values -// depth tracking prevents stack overflow from malicious deeply nested queries -func visitNode(node interface{}, inputs *[]string, depth int) { - if node == nil { - return - } + // visitNode(doc, &inputs, 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 + } - // Prevent excessive recursion - if depth > maxGraphQLRecursionDepth { - log.Warnf("GraphQL query exceeds maximum recursion depth of %d", maxGraphQLRecursionDepth) - return - } + if node, ok := p.Node.(*ast.StringValue); ok { + inputs = append(inputs, node.Value) + } - nextDepth := depth + 1 + return visitor.ActionNoChange, nil + }, + Leave: func(p visitor.VisitFuncParams) (string, interface{}) { + depth-- + return visitor.ActionNoChange, nil + }, + }, nil) - switch n := node.(type) { - case *ast.Document: - for _, def := range n.Definitions { - visitNode(def, inputs, nextDepth) - } - case *ast.OperationDefinition: - if n.SelectionSet != nil { - visitNode(n.SelectionSet, inputs, nextDepth) - } - for _, varDef := range n.VariableDefinitions { - visitNode(varDef, inputs, nextDepth) - } - case *ast.VariableDefinition: - if n.DefaultValue != nil { - visitNode(n.DefaultValue, inputs, nextDepth) - } - case *ast.SelectionSet: - for _, sel := range n.Selections { - visitNode(sel, inputs, nextDepth) - } - case *ast.Field: - for _, arg := range n.Arguments { - visitNode(arg, inputs, nextDepth) - } - if n.SelectionSet != nil { - visitNode(n.SelectionSet, inputs, nextDepth) - } - case *ast.Argument: - visitNode(n.Value, inputs, nextDepth) - case *ast.StringValue: - *inputs = append(*inputs, n.Value) - case *ast.IntValue: - // Skip int values - case *ast.FloatValue: - // Skip float values - case *ast.BooleanValue: - // Skip boolean values - case *ast.EnumValue: - // Skip enum values - case *ast.ListValue: - for _, val := range n.Values { - visitNode(val, inputs, nextDepth) - } - case *ast.ObjectValue: - for _, field := range n.Fields { - visitNode(field, inputs, nextDepth) - } - case *ast.ObjectField: - visitNode(n.Value, inputs, nextDepth) - case *ast.Variable: - // Skip variables (they are handled separately) - case *ast.FragmentDefinition: - if n.SelectionSet != nil { - visitNode(n.SelectionSet, inputs, nextDepth) - } - case *ast.InlineFragment: - if n.SelectionSet != nil { - visitNode(n.SelectionSet, inputs, nextDepth) - } - case *ast.FragmentSpread: - // Skip fragment spreads - } + return inputs } // ExtractTopLevelFields extracts the top-level fields from a GraphQL document // Returns the operation type (query/mutation) and field names -// Similar to firewall-node/library/sources/graphql/extractTopLevelFieldsFromDocument.ts func ExtractTopLevelFields(queryString string, operationName string) (operationType string, fields []string) { if queryString == "" { return "", nil diff --git a/lib/request-processor/utils/graphql_test.go b/lib/request-processor/utils/graphql_test.go index 101a607c6..622ad9df8 100644 --- a/lib/request-processor/utils/graphql_test.go +++ b/lib/request-processor/utils/graphql_test.go @@ -87,23 +87,6 @@ func TestExtractInputsFromGraphQL_POST(t *testing.T) { assert.Contains(t, result, "123") assert.Contains(t, result, "John") - // Test with variables - bodyWithVariables := map[string]interface{}{ - "query": `query GetUser($id: ID!) { user(id: $id) { id name } }`, - "variables": map[string]interface{}{ - "id": "456", - "name": "Jane", - "age": 30, // Non-string should be handled - "email": "jane@example.com", - }, - } - result = ExtractInputsFromGraphQL(bodyWithVariables, nil, "POST") - - // Should extract string variables - assert.Contains(t, result, "456") - assert.Contains(t, result, "Jane") - assert.Contains(t, result, "jane@example.com") - // "query": "mutation { uploadFile(url: \"http://localhost/secrets\") { success } }", bodyWithMutation := map[string]interface{}{ "query": "mutation { uploadFile(url: \"http://localhost/secrets\") { success } }", @@ -113,13 +96,10 @@ func TestExtractInputsFromGraphQL_POST(t *testing.T) { // Test with variables bodyWithVariablesMutation := map[string]interface{}{ - "query": "mutation { uploadFile(url: \"http://localhost/secrets\") { success } }", - "variables": map[string]interface{}{ - "url": "http://localhost/secrets", - }, + "query": "mutation { uploadFile(url: \"http://localhost2/secrets\") { success } }", } result = ExtractInputsFromGraphQL(bodyWithVariablesMutation, nil, "POST") - assert.Contains(t, result, "http://localhost/secrets") + assert.Contains(t, result, "http://localhost2/secrets") } func TestExtractInputsFromGraphQL_GET(t *testing.T) { From 6c500af4ec697da88aeb51f59be66582ca7ff685 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 3 Feb 2026 09:25:04 +0000 Subject: [PATCH 7/9] Refactor GraphQL --- lib/request-processor/context/cache.go | 31 ++++++------ tests/cli/graphql/test_graphql_get_query.phpt | 29 +++++++++++ ...t_graphql_path_traversal_in_variables.phpt | 38 -------------- ..._graphql_shell_injection_in_variables.phpt | 38 -------------- ...s.phpt => test_graphql_sql_injection.phpt} | 10 +--- .../test_graphql_ssrf_with_variables.phpt | 50 ------------------- 6 files changed, 48 insertions(+), 148 deletions(-) create mode 100644 tests/cli/graphql/test_graphql_get_query.phpt delete mode 100644 tests/cli/graphql/test_graphql_path_traversal_in_variables.phpt delete mode 100644 tests/cli/graphql/test_graphql_shell_injection_in_variables.phpt rename tests/cli/graphql/{test_graphql_sql_injection_in_variables.phpt => test_graphql_sql_injection.phpt} (75%) delete mode 100644 tests/cli/graphql/test_graphql_ssrf_with_variables.phpt diff --git a/lib/request-processor/context/cache.go b/lib/request-processor/context/cache.go index 35af93b84..d83421160 100644 --- a/lib/request-processor/context/cache.go +++ b/lib/request-processor/context/cache.go @@ -265,24 +265,27 @@ func ContextSetGraphQL() { // Get content-type from headers var contentType string headers := GetHeadersParsed() - contentType, ok := headers["content_type"].(string) - if ok { - contentType = strings.ToLower(strings.TrimSpace(contentType)) - body := GetBodyParsed() - query := GetQueryParsed() + if ct, ok := headers["content_type"].(string); ok { + contentType = ct + } else { + contentType = "" + } - isGraphQL := utils.IsGraphQLOverHTTP(method, url, contentType, body, query) + contentType = strings.ToLower(strings.TrimSpace(contentType)) + body := GetBodyParsed() + query := GetQueryParsed() - if isGraphQL { - log.Debug("Detected GraphQL request") + isGraphQL := utils.IsGraphQLOverHTTP(method, url, contentType, body, query) - // Extract GraphQL inputs - graphqlInputs := utils.ExtractInputsFromGraphQL(body, query, method) - Context.GraphQLParsedFlattened = &graphqlInputs + if isGraphQL { + log.Debug("Detected GraphQL request") - log.Debugf("Extracted %d GraphQL inputs", len(graphqlInputs)) - return - } + // Extract GraphQL inputs + graphqlInputs := utils.ExtractInputsFromGraphQL(body, query, method) + Context.GraphQLParsedFlattened = &graphqlInputs + + log.Debugf("Extracted %d GraphQL inputs", len(graphqlInputs)) + return } } 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_path_traversal_in_variables.phpt b/tests/cli/graphql/test_graphql_path_traversal_in_variables.phpt deleted file mode 100644 index 47159ec93..000000000 --- a/tests/cli/graphql/test_graphql_path_traversal_in_variables.phpt +++ /dev/null @@ -1,38 +0,0 @@ ---TEST-- -Test path traversal 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 GetFile($path: String!) { file(path: $path) { content } }", - "variables": { - "path": "../../etc/passwd" - } -} - ---FILE-- - - ---EXPECTREGEX-- -.*Detected GraphQL request.* -.*Aikido firewall has blocked a path traversal attack.* - diff --git a/tests/cli/graphql/test_graphql_shell_injection_in_variables.phpt b/tests/cli/graphql/test_graphql_shell_injection_in_variables.phpt deleted file mode 100644 index fdc3b74a4..000000000 --- a/tests/cli/graphql/test_graphql_shell_injection_in_variables.phpt +++ /dev/null @@ -1,38 +0,0 @@ ---TEST-- -Test shell 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": "mutation ExportData($filename: String!) { export(filename: $filename) { success } }", - "variables": { - "filename": "data.txt; cat /etc/passwd" - } -} - ---FILE-- - - ---EXPECTREGEX-- -.*Detected GraphQL request.* -.*Aikido firewall has blocked a shell injection.* - diff --git a/tests/cli/graphql/test_graphql_sql_injection_in_variables.phpt b/tests/cli/graphql/test_graphql_sql_injection.phpt similarity index 75% rename from tests/cli/graphql/test_graphql_sql_injection_in_variables.phpt rename to tests/cli/graphql/test_graphql_sql_injection.phpt index 9ce4e35c2..f61290063 100644 --- a/tests/cli/graphql/test_graphql_sql_injection_in_variables.phpt +++ b/tests/cli/graphql/test_graphql_sql_injection.phpt @@ -13,10 +13,7 @@ REQUEST_METHOD=POST Content-Type: application/json { - "query": "query GetUser($userId: String!) { user(id: $userId) { name } }", - "variables": { - "userId": "1' OR '1'='1" - } + "query": "query GetUser($userId: String!) { user(id: \"1' OR '1'='1\") { name } }" } --FILE-- @@ -30,10 +27,7 @@ try { name TEXT NOT NULL )"); - $input = file_get_contents('php://input'); - $data = json_decode($input, true); - - $userId = $data['variables']['userId'] ?? ''; + $userId = "1' OR '1'='1"; // Vulnerable SQL query $query = "SELECT * FROM users WHERE id = '" . $userId . "'"; diff --git a/tests/cli/graphql/test_graphql_ssrf_with_variables.phpt b/tests/cli/graphql/test_graphql_ssrf_with_variables.phpt deleted file mode 100644 index 3a8b2b59c..000000000 --- a/tests/cli/graphql/test_graphql_ssrf_with_variables.phpt +++ /dev/null @@ -1,50 +0,0 @@ ---TEST-- -Test SSRF detection in GraphQL with variables containing private IP - ---ENV-- -AIKIDO_LOG_LEVEL=DEBUG -AIKIDO_BLOCK=1 -REQUEST_URI=/index.php?p=admin/actions/graphql/api -HTTP_HOST=test.local -REQUEST_METHOD=POST - - ---POST_RAW-- -Content-Type: application/json - -{ - "query": "mutation SaveAsset($file: FileInput!) { save_asset(file: $file) { id } }", - "variables": { - "file": { - "url": "http://127.0.0.1:8080/admin", - "filename": "malicious.txt" - } - } -} - ---FILE-- - - ---EXPECTREGEX-- -.*Detected GraphQL request.* -.*Aikido firewall has blocked a server-side request forgery.* - From b6f6f556a5ceeb705325330f159abf72e038b29f Mon Sep 17 00:00:00 2001 From: root Date: Tue, 3 Feb 2026 09:39:27 +0000 Subject: [PATCH 8/9] Refactor GraphQL --- lib/request-processor/context/cache.go | 2 -- lib/request-processor/utils/graphql.go | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/request-processor/context/cache.go b/lib/request-processor/context/cache.go index d83421160..7be0001e1 100644 --- a/lib/request-processor/context/cache.go +++ b/lib/request-processor/context/cache.go @@ -255,10 +255,8 @@ func ContextSetGraphQL() { return } - // Default to not a GraphQL request with empty inputs Context.GraphQLParsedFlattened = &map[string]string{} - // Check if this is a GraphQL request method := GetMethod() url := GetUrl() diff --git a/lib/request-processor/utils/graphql.go b/lib/request-processor/utils/graphql.go index 2c31f547f..c00841d6d 100644 --- a/lib/request-processor/utils/graphql.go +++ b/lib/request-processor/utils/graphql.go @@ -145,7 +145,7 @@ func ExtractInputsFromGraphQL( if queryString != "" { inputs := extractStringValuesFromDocument(queryString) for _, input := range inputs { - result[input] = ".graphql.query" + result[input] = ".query" } } @@ -153,7 +153,7 @@ func ExtractInputsFromGraphQL( if variables != nil { varInputs := helpers.ExtractStringsFromUserInput(variables, []helpers.PathPart{{Type: "object", Key: "graphql.variables"}}, 0) for k, v := range varInputs { - result[k] = ".graphql.variables" + v + result[k] = ".variables" + v } } From 7cbc33a0b589c1396b89f20ff9946905634d7779 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 3 Feb 2026 11:38:42 +0000 Subject: [PATCH 9/9] Refactor GraphQL --- lib/request-processor/context/cache.go | 1 - lib/request-processor/utils/graphql.go | 33 +++++++------------------- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/lib/request-processor/context/cache.go b/lib/request-processor/context/cache.go index 7be0001e1..fed9ce90f 100644 --- a/lib/request-processor/context/cache.go +++ b/lib/request-processor/context/cache.go @@ -282,7 +282,6 @@ func ContextSetGraphQL() { graphqlInputs := utils.ExtractInputsFromGraphQL(body, query, method) Context.GraphQLParsedFlattened = &graphqlInputs - log.Debugf("Extracted %d GraphQL inputs", len(graphqlInputs)) return } diff --git a/lib/request-processor/utils/graphql.go b/lib/request-processor/utils/graphql.go index c00841d6d..c319dcc18 100644 --- a/lib/request-processor/utils/graphql.go +++ b/lib/request-processor/utils/graphql.go @@ -24,11 +24,11 @@ func IsGraphQLOverHTTP( return isGraphQLRoute(url) && isJSONContentType(contentType) && hasGraphQLQuery(body) && - looksLikeGraphQLQuery(getQueryString(body)) + looksLikeGraphQLQuery(extractQueryString(body)) } if method == "GET" { - queryStr := getQueryStringFromQueryParams(query) + queryStr := extractQueryString(query) return isGraphQLRoute(url) && queryStr != "" && looksLikeGraphQLQuery(queryStr) @@ -70,28 +70,12 @@ func hasGraphQLQuery(body map[string]interface{}) bool { return ok } -// getQueryString extracts the query string from the body -func getQueryString(body map[string]interface{}) string { - if body == nil { - return "" - } - queryField, exists := body["query"] - if !exists { - return "" - } - queryStr, ok := queryField.(string) - if !ok { - return "" - } - return queryStr -} - -// getQueryStringFromQueryParams extracts the query string from query parameters -func getQueryStringFromQueryParams(query map[string]interface{}) string { - if query == nil { +// 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 := query["query"] + queryField, exists := data["query"] if !exists { return "" } @@ -124,10 +108,10 @@ func ExtractInputsFromGraphQL( // Extract query and variables based on method if method == "POST" && body != nil { - queryString = getQueryString(body) + 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 = getQueryStringFromQueryParams(query) + queryString = extractQueryString(query) if varsField, exists := query["variables"]; exists { // Variables in GET requests might be JSON-encoded strings if varsStr, ok := varsField.(string); ok { @@ -181,7 +165,6 @@ func extractStringValuesFromDocument(queryString string) []string { // Recursively visit all nodes in the AST and extract string values // Start with depth 0 - // visitNode(doc, &inputs, 0) // Walk AST and collect string values // Walk AST with depth tracking depth := 0