From 66b18e466f7e12c165e7115f7ec0d2b178e79538 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 12 Aug 2026 13:30:56 +0545 Subject: [PATCH 1/2] fix(graphql-limit-count): measure each fragment once when computing query depth --- apisix/plugins/graphql-limit-count.lua | 22 +++++--- t/plugin/graphql-limit-count.t | 73 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/apisix/plugins/graphql-limit-count.lua b/apisix/plugins/graphql-limit-count.lua index af5a72e820f7..6ac0f3620748 100644 --- a/apisix/plugins/graphql-limit-count.lua +++ b/apisix/plugins/graphql-limit-count.lua @@ -90,8 +90,10 @@ local check_graphql_request = { -- Returns the maximum selection nesting depth of the GraphQL query AST. -- Fragment spreads are expanded in place using the provided fragment map; -- inline fragments are treated as transparent wrappers over their selections. --- The visited table guards against fragment definition cycles. -local function node_depth(node, fragments, visited) +-- visited guards against fragment definition cycles; memo caches each +-- fragment's resolved depth so a fragment is measured once regardless of how +-- many times it is spread, keeping the traversal linear in the document size. +local function node_depth(node, fragments, visited, memo) if type(node) ~= "table" then return 0 end @@ -101,13 +103,18 @@ local function node_depth(node, fragments, visited) if not name or visited[name] then return 0 end + local cached = memo[name] + if cached then + return cached + end local frag = fragments[name] if not frag or not frag.selectionSet then return 0 end visited[name] = true - local depth = node_depth(frag.selectionSet.selections, fragments, visited) + local depth = node_depth(frag.selectionSet.selections, fragments, visited, memo) visited[name] = nil + memo[name] = depth return depth end @@ -115,16 +122,16 @@ local function node_depth(node, fragments, visited) if not node.selectionSet then return 0 end - return node_depth(node.selectionSet.selections, fragments, visited) + return node_depth(node.selectionSet.selections, fragments, visited, memo) end local depth = 0 for k, v in pairs(node) do local child if k == "selections" then - child = 1 + node_depth(v, fragments, visited) + child = 1 + node_depth(v, fragments, visited, memo) else - child = node_depth(v, fragments, visited) + child = node_depth(v, fragments, visited, memo) end depth = max(depth, child) end @@ -191,8 +198,9 @@ function _M.access(conf, ctx) end local depth = 0 + local memo = {} for _, op in ipairs(operations) do - local d = node_depth(op, fragments, {}) + local d = node_depth(op, fragments, {}, memo) depth = max(depth, d) end depth = max(depth, 1) diff --git a/t/plugin/graphql-limit-count.t b/t/plugin/graphql-limit-count.t index f95ee31944e8..79a8bd44cd6c 100644 --- a/t/plugin/graphql-limit-count.t +++ b/t/plugin/graphql-limit-count.t @@ -633,3 +633,76 @@ Content-Type: application/json --- error_code: 200 --- response_headers X-RateLimit-Remaining: 15 + + + +=== TEST 27: set route: nested fragment expansion test +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "graphql-limit-count": { + "count": 1000000, + "time_window": 60, + "rejected_code": 503, + "key": "remote_addr" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 28: deeply reused fragments are measured once, not re-expanded +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local n = 34 + local parts = {"fragment f0 on Query { __typename }"} + for i = 1, n do + parts[#parts + 1] = string.format( + "fragment f%d on Query { ...f%d ...f%d }", i, i - 1, i - 1) + end + parts[#parts + 1] = string.format("query { ...f%d }", n) + local body = table.concat(parts, " ") + local httpc = http.new() + local res, err = httpc:request_uri( + "http://127.0.0.1:" .. ngx.var.server_port .. "/hello", { + method = "POST", + body = body, + headers = { ["Content-Type"] = "application/graphql" }, + }) + if not res then + ngx.say(err) + return + end + ngx.say(res.status) + } + } +--- request +GET /t +--- timeout: 10 +--- response_body +200 From 713a6947c8c97b66cee90156b882c51087b10209 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 12 Aug 2026 16:33:08 +0545 Subject: [PATCH 2/2] test(graphql-limit-count): assert computed depth for reused fragments --- t/plugin/graphql-limit-count.t | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/t/plugin/graphql-limit-count.t b/t/plugin/graphql-limit-count.t index 79a8bd44cd6c..22c705690dad 100644 --- a/t/plugin/graphql-limit-count.t +++ b/t/plugin/graphql-limit-count.t @@ -646,10 +646,11 @@ X-RateLimit-Remaining: 15 [[{ "plugins": { "graphql-limit-count": { - "count": 1000000, + "count": 100, "time_window": 60, "rejected_code": 503, - "key": "remote_addr" + "key": "remote_addr", + "show_limit_quota_header": true } }, "upstream": { @@ -674,13 +675,17 @@ passed -=== TEST 28: deeply reused fragments are measured once, not re-expanded +=== TEST 28: deeply reused fragments are measured once and keep their depth --- config location /t { content_by_lua_block { + -- f0 nests three field levels (depth 3); every fN spreads the + -- previous fragment twice, so a naive expansion is O(2^N) while + -- the result stays depth 3. A completed request with the exact + -- quota proves the traversal is both linear and depth-correct. local http = require "resty.http" local n = 34 - local parts = {"fragment f0 on Query { __typename }"} + local parts = {"fragment f0 on Query { a { b { c } } }"} for i = 1, n do parts[#parts + 1] = string.format( "fragment f%d on Query { ...f%d ...f%d }", i, i - 1, i - 1) @@ -699,6 +704,7 @@ passed return end ngx.say(res.status) + ngx.say(res.headers["X-RateLimit-Remaining"]) } } --- request @@ -706,3 +712,4 @@ GET /t --- timeout: 10 --- response_body 200 +97