diff --git a/javascript/ql/lib/semmle/javascript/security/dataflow/MissingRateLimiting.qll b/javascript/ql/lib/semmle/javascript/security/dataflow/MissingRateLimiting.qll index 5f4ad1b3d73e..8dd9c4831446 100644 --- a/javascript/ql/lib/semmle/javascript/security/dataflow/MissingRateLimiting.qll +++ b/javascript/ql/lib/semmle/javascript/security/dataflow/MissingRateLimiting.qll @@ -191,3 +191,21 @@ class RouteHandlerLimitedByRateLimiterFlexible extends RateLimitingMiddleware in private class FastifyRateLimiter extends RateLimitingMiddleware { FastifyRateLimiter() { this = DataFlow::moduleImport("fastify-rate-limit") } } + +/** + * An options object with a `rateLimit` config passed to a Fastify shorthand route method, + * such as `fastify.post('/path', { config: { rateLimit: { ... } } }, handler)`. + */ +private class FastifyPerRouteRateLimit extends RateLimitingMiddleware { + FastifyPerRouteRateLimit() { + exists(Fastify::RouteSetup setup | + not setup.getMethodName() = ["route", "addHook"] and + setup.getNumArgument() >= 3 and + this.flowsTo(setup.getArgument(1)) + | + exists(this.getAPropertySource("config").getAPropertySource("rateLimit")) + or + exists(this.getAPropertySource("rateLimit")) + ) + } +} diff --git a/javascript/ql/src/change-notes/2026-04-13-fastify-per-route-rate-limit.md b/javascript/ql/src/change-notes/2026-04-13-fastify-per-route-rate-limit.md new file mode 100644 index 000000000000..56d523885248 --- /dev/null +++ b/javascript/ql/src/change-notes/2026-04-13-fastify-per-route-rate-limit.md @@ -0,0 +1,5 @@ +--- +category: minorAnalysis +--- +* The query `js/missing-rate-limiting` now takes Fastify per-route + rate limiting into account. diff --git a/javascript/ql/test/query-tests/Security/CWE-770/MissingRateLimit/MissingRateLimiting.expected b/javascript/ql/test/query-tests/Security/CWE-770/MissingRateLimit/MissingRateLimiting.expected index 3d7bc2954eba..8d197d6e37f6 100644 --- a/javascript/ql/test/query-tests/Security/CWE-770/MissingRateLimit/MissingRateLimiting.expected +++ b/javascript/ql/test/query-tests/Security/CWE-770/MissingRateLimit/MissingRateLimiting.expected @@ -9,3 +9,4 @@ | tst.js:64:25:64:63 | functio ... req); } | This route handler performs $@, but is not rate-limited. | tst.js:64:46:64:60 | verifyUser(req) | authorization | | tst.js:76:25:76:53 | catchAs ... ndler1) | This route handler performs $@, but is not rate-limited. | tst.js:14:40:14:46 | login() | authorization | | tst.js:88:24:88:40 | expensiveHandler1 | This route handler performs $@, but is not rate-limited. | tst.js:14:40:14:46 | login() | authorization | +| tst.js:112:28:112:44 | expensiveHandler1 | This route handler performs $@, but is not rate-limited. | tst.js:14:40:14:46 | login() | authorization | diff --git a/javascript/ql/test/query-tests/Security/CWE-770/MissingRateLimit/tst.js b/javascript/ql/test/query-tests/Security/CWE-770/MissingRateLimit/tst.js index 4f778afef684..5b4312bbbe0e 100644 --- a/javascript/ql/test/query-tests/Security/CWE-770/MissingRateLimit/tst.js +++ b/javascript/ql/test/query-tests/Security/CWE-770/MissingRateLimit/tst.js @@ -88,3 +88,25 @@ const fastifyApp = require('fastify')(); fastifyApp.get('/foo', expensiveHandler1); // $ Alert fastifyApp.register(require('fastify-rate-limit')); fastifyApp.get('/bar', expensiveHandler1); + +// Fastify per-route rate limiting via config.rateLimit +const fastifyApp2 = require('fastify')(); +fastifyApp2.register(require('@fastify/rate-limit')); + +fastifyApp2.post('/login', { + config: { + rateLimit: { + max: 3, + timeWindow: '1 minute' + } + } +}, expensiveHandler1); // OK - has per-route rateLimit config + +fastifyApp2.post('/signup', { + rateLimit: { + max: 5, + timeWindow: '1 minute' + } +}, expensiveHandler1); // OK - has per-route rateLimit directly in options + +fastifyApp2.post('/other', expensiveHandler1); // $ Alert - no rate limiting