Skip to content

Commit d4483b2

Browse files
committed
Merge branch 'master' into feat/graphql-query-cost
master rewrote the query depth walk to memoise each fragment and to reject fragment cycles (#13809); this branch had moved the same walk behind query_depth to make room for the cost strategies. Keep both: node_depth is master's, and query_depth carries the memo and the cycle report through to the access phase, which still answers 400 with the same message.
2 parents 44ff5b6 + 0392290 commit d4483b2

2 files changed

Lines changed: 160 additions & 18 deletions

File tree

apisix/plugins/graphql-limit-count.lua

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -173,41 +173,54 @@ local check_graphql_request = {
173173
-- Returns the maximum selection nesting depth of the GraphQL query AST.
174174
-- Fragment spreads are expanded in place using the provided fragment map;
175175
-- inline fragments are treated as transparent wrappers over their selections.
176-
-- The visited table guards against fragment definition cycles.
177-
local function node_depth(node, fragments, visited)
176+
-- visited tracks the current recursion path: a spread back onto it is a
177+
-- fragment cycle (invalid GraphQL), flagged via cycle.found so the caller can
178+
-- reject the request instead of trusting a traversal-order-dependent depth.
179+
-- memo caches each fragment's resolved depth so a fragment is measured once,
180+
-- keeping the traversal linear in the document size.
181+
local function node_depth(node, fragments, visited, memo, cycle)
178182
if type(node) ~= "table" then
179183
return 0
180184
end
181185

182186
if node.kind == "fragmentSpread" then
183187
local name = node.name and node.name.value
184-
if not name or visited[name] then
188+
if not name then
185189
return 0
186190
end
191+
if visited[name] then
192+
cycle.found = true
193+
return 0
194+
end
195+
local cached = memo[name]
196+
if cached then
197+
return cached
198+
end
187199
local frag = fragments[name]
188200
if not frag or not frag.selectionSet then
189201
return 0
190202
end
191203
visited[name] = true
192-
local depth = node_depth(frag.selectionSet.selections, fragments, visited)
204+
local depth = node_depth(frag.selectionSet.selections, fragments, visited, memo, cycle)
193205
visited[name] = nil
206+
memo[name] = depth
194207
return depth
195208
end
196209

197210
if node.kind == "inlineFragment" then
198211
if not node.selectionSet then
199212
return 0
200213
end
201-
return node_depth(node.selectionSet.selections, fragments, visited)
214+
return node_depth(node.selectionSet.selections, fragments, visited, memo, cycle)
202215
end
203216

204217
local depth = 0
205218
for k, v in pairs(node) do
206219
local child
207220
if k == "selections" then
208-
child = 1 + node_depth(v, fragments, visited)
221+
child = 1 + node_depth(v, fragments, visited, memo, cycle)
209222
else
210-
child = node_depth(v, fragments, visited)
223+
child = node_depth(v, fragments, visited, memo, cycle)
211224
end
212225
depth = max(depth, child)
213226
end
@@ -216,13 +229,24 @@ local function node_depth(node, fragments, visited)
216229
end
217230

218231

232+
-- Returns the depth, or nil plus the log line and the client message when the
233+
-- document is not valid GraphQL to begin with.
219234
local function query_depth(operations, fragments)
220235
local depth = 0
236+
local memo = {}
237+
local cycle = {found = false}
221238
for _, op in ipairs(operations) do
222-
depth = max(depth, node_depth(op, fragments, {}))
239+
depth = max(depth, node_depth(op, fragments, {}, memo, cycle))
223240
end
224241

225-
return max(depth, 1)
242+
if cycle.found then
243+
return nil, "invalid graphql request: fragment spreads form a cycle",
244+
"Invalid graphql request: fragment spreads must not form cycles"
245+
end
246+
247+
depth = max(depth, 1)
248+
core.log.info("graphql node depth: ", depth)
249+
return depth
226250
end
227251

228252

@@ -334,12 +358,16 @@ function _M.access(conf, ctx)
334358
end
335359
end
336360

337-
local raw_cost
338-
raw_cost, err = raw_query_cost(conf, ctx, operations, fragments, variables)
361+
local raw_cost, client_msg
362+
raw_cost, err, client_msg = raw_query_cost(conf, ctx, operations, fragments, variables)
339363
if not raw_cost then
340-
core.log.error("failed to compute the graphql query cost: ", err)
341-
return 400, {message = "Invalid graphql request: failed to introspect the " ..
342-
"upstream graphql schema"}
364+
-- a malformed document reports its own message; anything else is the
365+
-- introspection failing
366+
core.log.error(client_msg and err
367+
or "failed to compute the graphql query cost: " .. err)
368+
return 400, {message = client_msg
369+
or "Invalid graphql request: failed to introspect the "
370+
.. "upstream graphql schema"}
343371
end
344372

345373
-- The +0.01 floor makes a query whose nodes are all undecorated still cost 1.

t/plugin/graphql-limit-count.t

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -507,17 +507,19 @@ X-RateLimit-Remaining: 16
507507
508508
509509
510-
=== TEST 22: fragment cycle does not cause infinite recursion
510+
=== TEST 22: fragment cycle is rejected
511511
--- request
512512
POST /hello
513513
{
514514
"query": "query { user { ...CycleA } } fragment CycleA on User { id { ...CycleA } }"
515515
}
516516
--- more_headers
517517
Content-Type: application/json
518-
--- error_code: 200
519-
--- response_headers_like
520-
X-RateLimit-Remaining: \d+
518+
--- error_code: 400
519+
--- error_log
520+
invalid graphql request: fragment spreads form a cycle
521+
--- response_body eval
522+
qr/fragment spreads must not form cycles/
521523
522524
523525
@@ -633,3 +635,115 @@ Content-Type: application/json
633635
--- error_code: 200
634636
--- response_headers
635637
X-RateLimit-Remaining: 15
638+
639+
640+
641+
=== TEST 27: set route: nested fragment expansion test
642+
--- config
643+
location /t {
644+
content_by_lua_block {
645+
local t = require("lib.test_admin").test
646+
local code, body = t('/apisix/admin/routes/1',
647+
ngx.HTTP_PUT,
648+
[[{
649+
"plugins": {
650+
"graphql-limit-count": {
651+
"count": 100,
652+
"time_window": 60,
653+
"rejected_code": 503,
654+
"key": "remote_addr",
655+
"show_limit_quota_header": true
656+
}
657+
},
658+
"upstream": {
659+
"nodes": {
660+
"127.0.0.1:1980": 1
661+
},
662+
"type": "roundrobin"
663+
},
664+
"uri": "/hello"
665+
}]]
666+
)
667+
if code >= 300 then
668+
ngx.status = code
669+
end
670+
ngx.say(body)
671+
}
672+
}
673+
--- request
674+
GET /t
675+
--- response_body
676+
passed
677+
678+
679+
680+
=== TEST 28: deeply reused fragments are measured once and keep their depth
681+
--- config
682+
location /t {
683+
content_by_lua_block {
684+
-- f0 nests three field levels (depth 3); every fN spreads the
685+
-- previous fragment twice, so a naive expansion is O(2^N) while
686+
-- the result stays depth 3. A completed request with the exact
687+
-- quota proves the traversal is both linear and depth-correct.
688+
local http = require "resty.http"
689+
local n = 34
690+
local parts = {"fragment f0 on Query { a { b { c } } }"}
691+
for i = 1, n do
692+
parts[#parts + 1] = string.format(
693+
"fragment f%d on Query { ...f%d ...f%d }", i, i - 1, i - 1)
694+
end
695+
parts[#parts + 1] = string.format("query { ...f%d }", n)
696+
local body = table.concat(parts, " ")
697+
local httpc = http.new()
698+
local res, err = httpc:request_uri(
699+
"http://127.0.0.1:" .. ngx.var.server_port .. "/hello", {
700+
method = "POST",
701+
body = body,
702+
headers = { ["Content-Type"] = "application/graphql" },
703+
})
704+
if not res then
705+
ngx.say(err)
706+
return
707+
end
708+
ngx.say(res.status)
709+
ngx.say(res.headers["X-RateLimit-Remaining"])
710+
}
711+
}
712+
--- request
713+
GET /t
714+
--- timeout: 10
715+
--- response_body
716+
200
717+
97
718+
719+
720+
721+
=== TEST 29: mutually recursive fragments are rejected
722+
--- request
723+
POST /hello
724+
{
725+
"query": "query { ...A } fragment A on Query { x { ...B } } fragment B on Query { y { ...A } }"
726+
}
727+
--- more_headers
728+
Content-Type: application/json
729+
--- error_code: 400
730+
--- error_log
731+
invalid graphql request: fragment spreads form a cycle
732+
--- response_body eval
733+
qr/fragment spreads must not form cycles/
734+
735+
736+
737+
=== TEST 30: mutual recursion reached at a different depth and order is rejected
738+
--- request
739+
POST /hello
740+
{
741+
"query": "query { ...B } fragment A on Query { ...B } fragment B on Query { m { n { ...A } } }"
742+
}
743+
--- more_headers
744+
Content-Type: application/json
745+
--- error_code: 400
746+
--- error_log
747+
invalid graphql request: fragment spreads form a cycle
748+
--- response_body eval
749+
qr/fragment spreads must not form cycles/

0 commit comments

Comments
 (0)