From 2b19c84151f0964d7ed8b48766f1c96e88adea96 Mon Sep 17 00:00:00 2001 From: dtorresfgf Date: Fri, 11 Sep 2026 06:20:54 +1000 Subject: [PATCH 1/2] JS: recognize Fastify servers reached through chainable server methods `Fastify::server()` tracks the server from the `fastify()` invocation, but did not step through methods that configure the instance and return it, such as `withTypeProvider()` and the `set*` family. On a server built as `fastify().withTypeProvider()` the instance was therefore not recognized as a server at all, so neither the plugins registered on it nor the routes declared on it were attributed to it. The effect ran in both directions. Routes on such an instance could be missed entirely, and where they were still reported through another model, a globally registered plugin such as `@fastify/rate-limit` was not seen as guarding them, which produced false positives in `js/missing-rate-limiting`. Route-registering methods (`register`, `addHook`, and the shorthand route methods) also return the server, but they are deliberately left out: they already have a meaning in the routing model, so including them would change the shape of the routing tree rather than only how a server reference is resolved. The step is added inside the type-tracked predicate rather than to the public one, so a chained instance is still resolved when it crosses a function boundary, for example when it is returned from a factory function. A test covers that case, and it fails if the step is placed in the public predicate instead. Co-Authored-By: Claude Opus 5 --- ...-09-11-fastify-chainable-config-methods.md | 4 ++ .../semmle/javascript/frameworks/Fastify.qll | 24 +++++++++++ .../frameworks/fastify/src/fastify.js | 10 +++++ .../frameworks/fastify/tests.expected | 7 ++++ .../MissingRateLimiting.expected | 2 + .../Security/CWE-770/MissingRateLimit/tst.js | 42 +++++++++++++++++++ 6 files changed, 89 insertions(+) create mode 100644 javascript/ql/lib/change-notes/2026-09-11-fastify-chainable-config-methods.md diff --git a/javascript/ql/lib/change-notes/2026-09-11-fastify-chainable-config-methods.md b/javascript/ql/lib/change-notes/2026-09-11-fastify-chainable-config-methods.md new file mode 100644 index 000000000000..986788d62111 --- /dev/null +++ b/javascript/ql/lib/change-notes/2026-09-11-fastify-chainable-config-methods.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* Fastify servers reached through a chainable configuration method, such as `fastify().withTypeProvider()` or `fastify().setValidatorCompiler(...)`, are now recognized as the same server instance. Routes registered on such an instance are now attributed to their server, which may add results for queries such as `js/missing-rate-limiting` where routes were previously not recognized at all, and remove false positives where a globally registered plugin guards them. diff --git a/javascript/ql/lib/semmle/javascript/frameworks/Fastify.qll b/javascript/ql/lib/semmle/javascript/frameworks/Fastify.qll index 26dde3fc78bd..a681bed23087 100644 --- a/javascript/ql/lib/semmle/javascript/frameworks/Fastify.qll +++ b/javascript/ql/lib/semmle/javascript/frameworks/Fastify.qll @@ -21,6 +21,25 @@ module Fastify { StandardServerDefinition() { this = DataFlow::moduleImport("fastify").getAnInvocation() } } + /** + * Gets the name of a chainable Fastify server method, that is, a configuration or + * lifecycle method that returns the same server instance it was called on, so that a + * call to it still refers to that server. + * + * Route-registering methods such as `register`, `addHook`, and the shorthand route + * methods return the server as well, but they are deliberately excluded here because + * they already have a meaning in the routing model for Fastify. + */ + private string chainableServerMethodName() { + result = + [ + "withTypeProvider", "addSchema", "addHttpMethod", "decorate", "decorateRequest", + "decorateReply", "setValidatorCompiler", "setSerializerCompiler", "setSchemaController", + "setReplySerializer", "setSchemaErrorFormatter", "setErrorHandler", "setNotFoundHandler", + "setGenReqId", "setChildLoggerFactory", "after", "ready" + ] + } + /** Gets a data flow node referring to a fastify server. */ private DataFlow::SourceNode server(DataFlow::SourceNode creation, DataFlow::TypeTracker t) { t.start() and @@ -31,6 +50,11 @@ module Fastify { t.start() and result = pluginCallback(creation).(DataFlow::FunctionNode).getParameter(0) or + // server.withTypeProvider(), server.setValidatorCompiler(...), and friends return + // the server itself, so the result of such a call still refers to it. + t.start() and + result = server(creation).getAMethodCall(chainableServerMethodName()) + or exists(DataFlow::TypeTracker t2 | result = server(creation, t2).track(t2, t)) } diff --git a/javascript/ql/test/library-tests/frameworks/fastify/src/fastify.js b/javascript/ql/test/library-tests/frameworks/fastify/src/fastify.js index 8c5264b50e8f..c65b739d726c 100644 --- a/javascript/ql/test/library-tests/frameworks/fastify/src/fastify.js +++ b/javascript/ql/test/library-tests/frameworks/fastify/src/fastify.js @@ -90,3 +90,13 @@ fastifyWithObjects4.post( request.params; } ); + +// the server is reached through a chainable configuration method, which returns the +// same instance +var fastifyChained = require("fastify")().withTypeProvider(); +fastifyChained.get( + "/", + /* handler */ (request, reply) => { + reply.send({ hello: "world" }); // response + } +); diff --git a/javascript/ql/test/library-tests/frameworks/fastify/tests.expected b/javascript/ql/test/library-tests/frameworks/fastify/tests.expected index a0f2fd1db671..e2b3b303a6ea 100644 --- a/javascript/ql/test/library-tests/frameworks/fastify/tests.expected +++ b/javascript/ql/test/library-tests/frameworks/fastify/tests.expected @@ -7,6 +7,7 @@ test_RouteSetup | src/fastify.js:63:1:70:1 | fastify ... ;\\n }\\n) | | src/fastify.js:74:1:81:1 | fastify ... ;\\n }\\n) | | src/fastify.js:85:1:92:1 | fastify ... ;\\n }\\n) | +| src/fastify.js:97:1:102:1 | fastify ... e\\n }\\n) | test_HeaderAccess | src/fastify.js:39:5:39:24 | request.headers.name | name | test_RouteHandler @@ -25,6 +26,7 @@ test_RouteHandler | src/fastify.js:65:17:69:3 | functio ... ms;\\n } | src/fastify.js:61:27:61:46 | require("fastify")() | | src/fastify.js:76:17:80:3 | functio ... ms;\\n } | src/fastify.js:72:27:72:46 | require("fastify")() | | src/fastify.js:87:17:91:3 | functio ... ms;\\n } | src/fastify.js:83:27:83:46 | require("fastify")() | +| src/fastify.js:99:17:101:3 | (reques ... nse\\n } | src/fastify.js:96:22:96:41 | require("fastify")() | test_HeaderDefinition | src/fastify.js:42:5:42:33 | reply.h ... value") | src/fastify.js:34:17:46:3 | functio ... eam\\n } | | src/fastify.js:43:5:43:36 | reply.h ... lue" }) | src/fastify.js:34:17:46:3 | functio ... eam\\n } | @@ -34,6 +36,7 @@ test_ServerDefinition | src/fastify.js:61:27:61:46 | require("fastify")() | | src/fastify.js:72:27:72:46 | require("fastify")() | | src/fastify.js:83:27:83:46 | require("fastify")() | +| src/fastify.js:96:22:96:41 | require("fastify")() | test_RedirectInvocation | src/fastify.js:44:5:44:29 | reply.r ... e, url) | src/fastify.js:34:17:46:3 | functio ... eam\\n } | test_RequestInputAccess @@ -57,6 +60,7 @@ test_ResponseSendArgument | src/fastify.js:6:12:6:29 | { hello: "world" } | src/fastify.js:5:17:7:3 | async ( ... nse\\n } | | src/fastify.js:27:16:27:33 | { hello: "world" } | src/fastify.js:26:17:28:3 | (reques ... nse\\n } | | src/fastify.js:45:16:45:22 | payload | src/fastify.js:34:17:46:3 | functio ... eam\\n } | +| src/fastify.js:100:16:100:33 | { hello: "world" } | src/fastify.js:99:17:101:3 | (reques ... nse\\n } | test_RouteSetup_getServer | src/fastify.js:3:1:8:1 | fastify ... e\\n }\\n) | src/fastify.js:1:15:1:34 | require("fastify")() | | src/fastify.js:10:1:21:2 | fastify ... > {}\\n}) | src/fastify.js:1:15:1:34 | require("fastify")() | @@ -66,6 +70,7 @@ test_RouteSetup_getServer | src/fastify.js:63:1:70:1 | fastify ... ;\\n }\\n) | src/fastify.js:61:27:61:46 | require("fastify")() | | src/fastify.js:74:1:81:1 | fastify ... ;\\n }\\n) | src/fastify.js:72:27:72:46 | require("fastify")() | | src/fastify.js:85:1:92:1 | fastify ... ;\\n }\\n) | src/fastify.js:83:27:83:46 | require("fastify")() | +| src/fastify.js:97:1:102:1 | fastify ... e\\n }\\n) | src/fastify.js:96:22:96:41 | require("fastify")() | test_HeaderDefinition_defines | src/fastify.js:42:5:42:33 | reply.h ... value") | name | value | | src/fastify.js:43:5:43:36 | reply.h ... lue" }) | name | value | @@ -85,6 +90,7 @@ test_RouteSetup_getARouteHandler | src/fastify.js:63:1:70:1 | fastify ... ;\\n }\\n) | src/fastify.js:65:17:69:3 | functio ... ms;\\n } | | src/fastify.js:74:1:81:1 | fastify ... ;\\n }\\n) | src/fastify.js:76:17:80:3 | functio ... ms;\\n } | | src/fastify.js:85:1:92:1 | fastify ... ;\\n }\\n) | src/fastify.js:87:17:91:3 | functio ... ms;\\n } | +| src/fastify.js:97:1:102:1 | fastify ... e\\n }\\n) | src/fastify.js:99:17:101:3 | (reques ... nse\\n } | test_RouteHandler_getARequestExpr | src/fastify.js:5:17:7:3 | async ( ... nse\\n } | src/fastify.js:5:24:5:30 | request | | src/fastify.js:13:28:13:55 | (reques ... ) => {} | src/fastify.js:13:29:13:35 | request | @@ -122,6 +128,7 @@ test_RouteHandler_getARequestExpr | src/fastify.js:87:17:91:3 | functio ... ms;\\n } | src/fastify.js:88:5:88:11 | request | | src/fastify.js:87:17:91:3 | functio ... ms;\\n } | src/fastify.js:89:5:89:11 | request | | src/fastify.js:87:17:91:3 | functio ... ms;\\n } | src/fastify.js:90:5:90:11 | request | +| src/fastify.js:99:17:101:3 | (reques ... nse\\n } | src/fastify.js:99:18:99:24 | request | test_HeaderDefinition_getAHeaderName | src/fastify.js:42:5:42:33 | reply.h ... value") | name | | src/fastify.js:43:5:43:36 | reply.h ... lue" }) | name | 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 5e2265f64b49..b1351269aeb6 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 @@ -11,3 +11,5 @@ | 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:111:28:111:44 | expensiveHandler1 | This route handler performs $@, but is not rate-limited. | tst.js:14:40:14:46 | login() | authorization | | tst.js:116:39:116:55 | expensiveHandler1 | This route handler performs $@, but is not rate-limited. | tst.js:14:40:14:46 | login() | authorization | +| tst.js:130:35:130:51 | expensiveHandler1 | This route handler performs $@, but is not rate-limited. | tst.js:14:40:14:46 | login() | authorization | +| tst.js:160:35:160:51 | 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 7ff0c067fb51..3efe7908f56e 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 @@ -116,3 +116,45 @@ const fastifyApp3 = require('fastify')(); fastifyApp3.get('/before-rate-limit', expensiveHandler1); // $ Alert fastifyApp3.register(require('@fastify/rate-limit')); fastifyApp3.get('/after-rate-limit', expensiveHandler1); + +// the server instance is reached through a chainable configuration method, which +// returns the same instance +const fastifyApp4 = require('fastify')().withTypeProvider(); + +fastifyApp4.register(require('@fastify/rate-limit')); +fastifyApp4.get('/after-rate-limit', expensiveHandler1); + +// same, but with no rate limiter registered at all, so the route is genuinely unguarded +const fastifyApp5 = require('fastify')().withTypeProvider(); + +fastifyApp5.get('/no-rate-limit', expensiveHandler1); // $ Alert + +// several configuration methods chained together +const fastifyApp6 = require('fastify')() + .withTypeProvider() + .setValidatorCompiler(compiler) + .decorate('answer', 42); + +fastifyApp6.register(require('@fastify/rate-limit')); +fastifyApp6.get('/after-rate-limit', expensiveHandler1); + +// the chained instance is returned from a factory function, so reaching it requires +// tracking the value across the call rather than only through local references +function makeFastifyApp() { + return require('fastify')().withTypeProvider(); +} + +const fastifyApp7 = makeFastifyApp(); + +fastifyApp7.register(require('@fastify/rate-limit')); +fastifyApp7.get('/after-rate-limit', expensiveHandler1); + +// same, from a separate factory so that the server above does not share its creation +// site, and no rate limiter is registered on it +function makeUnguardedFastifyApp() { + return require('fastify')().withTypeProvider(); +} + +const fastifyApp8 = makeUnguardedFastifyApp(); + +fastifyApp8.get('/no-rate-limit', expensiveHandler1); // $ Alert From f5fa010351acb537341dd6dde30265509126fbf6 Mon Sep 17 00:00:00 2001 From: dtorresfgf Date: Fri, 11 Sep 2026 06:39:58 +1000 Subject: [PATCH 2/2] Add addContentTypeParser, drop after and ready, and date the change note in UTC `addContentTypeParser` returns the server, so it belongs in the list. Its type declaration says `void`, which is why it was missed: it is declared as a property of an interface type rather than as a method, so it did not look like the `set*` family. Checking the rest of the instance against the runtime rather than against the declarations then showed that `ready` never returns the server, in either of its call forms, despite the declaration saying it does, and that `after` returns it only when given a callback. Both are now excluded, which also makes the predicate exactly what its name says. `onClose` returns the server too, but it registers a hook, so it is excluded for the same reason as `addHook`. Co-Authored-By: Claude Opus 5 --- ...09-10-fastify-chainable-config-methods.md} | 0 .../semmle/javascript/frameworks/Fastify.qll | 26 ++++++++++--------- .../MissingRateLimiting.expected | 2 +- .../Security/CWE-770/MissingRateLimit/tst.js | 1 + 4 files changed, 16 insertions(+), 13 deletions(-) rename javascript/ql/lib/change-notes/{2026-09-11-fastify-chainable-config-methods.md => 2026-09-10-fastify-chainable-config-methods.md} (100%) diff --git a/javascript/ql/lib/change-notes/2026-09-11-fastify-chainable-config-methods.md b/javascript/ql/lib/change-notes/2026-09-10-fastify-chainable-config-methods.md similarity index 100% rename from javascript/ql/lib/change-notes/2026-09-11-fastify-chainable-config-methods.md rename to javascript/ql/lib/change-notes/2026-09-10-fastify-chainable-config-methods.md diff --git a/javascript/ql/lib/semmle/javascript/frameworks/Fastify.qll b/javascript/ql/lib/semmle/javascript/frameworks/Fastify.qll index a681bed23087..63b6072d8ffa 100644 --- a/javascript/ql/lib/semmle/javascript/frameworks/Fastify.qll +++ b/javascript/ql/lib/semmle/javascript/frameworks/Fastify.qll @@ -22,21 +22,23 @@ module Fastify { } /** - * Gets the name of a chainable Fastify server method, that is, a configuration or - * lifecycle method that returns the same server instance it was called on, so that a - * call to it still refers to that server. + * Gets the name of a chainable Fastify configuration method, that is, a method that + * configures the server instance and returns that same instance, so that a call to it + * still refers to the server. * - * Route-registering methods such as `register`, `addHook`, and the shorthand route - * methods return the server as well, but they are deliberately excluded here because - * they already have a meaning in the routing model for Fastify. + * Plugin, hook and route registration (`register`, `addHook`, `onClose`, and the + * shorthand route methods) returns the server as well, but is deliberately excluded + * here, because it already has a meaning in the routing model for Fastify. The + * lifecycle methods `after` and `ready` are excluded too: `after` returns the server + * only when it is given a callback, and `ready` never does. */ - private string chainableServerMethodName() { + private string chainableConfigMethodName() { result = [ - "withTypeProvider", "addSchema", "addHttpMethod", "decorate", "decorateRequest", - "decorateReply", "setValidatorCompiler", "setSerializerCompiler", "setSchemaController", - "setReplySerializer", "setSchemaErrorFormatter", "setErrorHandler", "setNotFoundHandler", - "setGenReqId", "setChildLoggerFactory", "after", "ready" + "withTypeProvider", "addSchema", "addHttpMethod", "addContentTypeParser", "decorate", + "decorateRequest", "decorateReply", "setValidatorCompiler", "setSerializerCompiler", + "setSchemaController", "setReplySerializer", "setSchemaErrorFormatter", "setErrorHandler", + "setNotFoundHandler", "setGenReqId", "setChildLoggerFactory" ] } @@ -53,7 +55,7 @@ module Fastify { // server.withTypeProvider(), server.setValidatorCompiler(...), and friends return // the server itself, so the result of such a call still refers to it. t.start() and - result = server(creation).getAMethodCall(chainableServerMethodName()) + result = server(creation).getAMethodCall(chainableConfigMethodName()) or exists(DataFlow::TypeTracker t2 | result = server(creation, t2).track(t2, t)) } 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 b1351269aeb6..e8e1be279447 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 @@ -12,4 +12,4 @@ | tst.js:111:28:111:44 | expensiveHandler1 | This route handler performs $@, but is not rate-limited. | tst.js:14:40:14:46 | login() | authorization | | tst.js:116:39:116:55 | expensiveHandler1 | This route handler performs $@, but is not rate-limited. | tst.js:14:40:14:46 | login() | authorization | | tst.js:130:35:130:51 | expensiveHandler1 | This route handler performs $@, but is not rate-limited. | tst.js:14:40:14:46 | login() | authorization | -| tst.js:160:35:160:51 | expensiveHandler1 | This route handler performs $@, but is not rate-limited. | tst.js:14:40:14:46 | login() | authorization | +| tst.js:161:35:161:51 | 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 3efe7908f56e..7c5f1708ff6c 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 @@ -133,6 +133,7 @@ fastifyApp5.get('/no-rate-limit', expensiveHandler1); // $ Alert const fastifyApp6 = require('fastify')() .withTypeProvider() .setValidatorCompiler(compiler) + .addContentTypeParser('application/json', parser) .decorate('answer', 42); fastifyApp6.register(require('@fastify/rate-limit'));