From 47b4e9e96db36f4ff6cb34efd8659e5fa5d9474a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 29 May 2026 10:50:19 +0200 Subject: [PATCH 1/2] Check metadata of uploaded files --- library/agent/Context.ts | 2 + library/agent/Source.ts | 1 + .../helpers/getSourceForUserString.test.ts | 74 +++++++ library/package-lock.json | 192 ++++++++++++++++++ library/package.json | 6 +- library/sources/Express.tests.ts | 91 +++++++++ library/sources/HTTPServer.test.ts | 163 ++++++++++++++- library/sources/express/contextFromRequest.ts | 5 +- .../sources/http-server/contextFromRequest.ts | 2 + .../http-server/createRequestListener.ts | 18 +- library/sources/http-server/readBodyStream.ts | 32 ++- .../checkContextForPathTraversal.test.ts | 117 +++++++++++ .../checkContextForSqlInjection.test.ts | 41 ++++ 13 files changed, 732 insertions(+), 12 deletions(-) diff --git a/library/agent/Context.ts b/library/agent/Context.ts index 378aa96cb..87c2cf5ea 100644 --- a/library/agent/Context.ts +++ b/library/agent/Context.ts @@ -27,6 +27,7 @@ export type Context = { rawBody?: unknown; subdomains?: string[]; // https://expressjs.com/en/5x/api.html#req.subdomains markUnsafe?: unknown[]; + files?: unknown; // Multipart file metadata cache?: ReturnType; cachePathTraversal?: ReturnType; /** @@ -97,6 +98,7 @@ export function runWithContext(context: Context, fn: () => T) { current.subdomains = context.subdomains; current.outgoingRequestRedirects = context.outgoingRequestRedirects; current.markUnsafe = context.markUnsafe; + current.files = context.files; // Clear all the cached user input strings delete current.cache; diff --git a/library/agent/Source.ts b/library/agent/Source.ts index 5731835db..98b7e01f6 100644 --- a/library/agent/Source.ts +++ b/library/agent/Source.ts @@ -10,6 +10,7 @@ export const SOURCES = [ "markUnsafe", "url", "rawBody", + "files", ] as const; export type Source = (typeof SOURCES)[number]; diff --git a/library/helpers/getSourceForUserString.test.ts b/library/helpers/getSourceForUserString.test.ts index 5aeb17ca4..ca549393d 100644 --- a/library/helpers/getSourceForUserString.test.ts +++ b/library/helpers/getSourceForUserString.test.ts @@ -38,3 +38,77 @@ t.test( ); } ); + +t.test( + "it returns 'files' source when the user string is in multipart file metadata", + async () => { + const context = createContext(); + context.files = [ + { + fieldname: "document", + originalname: "' OR '1'='1", + encoding: "7bit", + mimetype: "application/pdf", + }, + ]; + t.same(getSourceForUserString(context, "' OR '1'='1"), "files"); + } +); + +t.test( + "it returns 'files' source when the user string is in the filename of the second file", + async () => { + const context = createContext(); + context.files = [ + { + fieldname: "photo", + filename: "benign.jpg", + encoding: "7bit", + mimeType: "image/jpeg", + }, + { + fieldname: "attachment", + filename: "../../etc/passwd", + encoding: "7bit", + mimeType: "text/plain", + }, + ]; + t.same(getSourceForUserString(context, "../../etc/passwd"), "files"); + } +); + +t.test( + "it returns 'files' source when the user string matches a mimeType field", + async () => { + const context = createContext(); + context.files = [ + { + fieldname: "upload", + filename: "safe.txt", + encoding: "7bit", + mimeType: "'; DROP TABLE users; --", + }, + ]; + t.same(getSourceForUserString(context, "'; DROP TABLE users; --"), "files"); + } +); + +t.test("it returns undefined when files array is empty", async () => { + const context = createContext(); + context.files = []; + t.same(getSourceForUserString(context, "anything"), undefined); +}); + +t.test( + "it returns 'files' source when files is a single object (req.file style)", + async () => { + const context = createContext(); + context.files = { + fieldname: "avatar", + originalname: "; rm -rf /", + encoding: "7bit", + mimetype: "image/png", + }; + t.same(getSourceForUserString(context, "; rm -rf /"), "files"); + } +); diff --git a/library/package-lock.json b/library/package-lock.json index f73a8b45b..8907be79f 100644 --- a/library/package-lock.json +++ b/library/package-lock.json @@ -16,6 +16,7 @@ "@aws-sdk/client-bedrock-runtime": "3.929.0", "@clickhouse/client": "^1.7.0", "@fastify/cookie": "^11.0.2", + "@fastify/multipart": "^10.0.0", "@google-cloud/functions-framework": "^5.0.0", "@google-cloud/pubsub": "^5.0.0", "@google/genai-v1": "npm:@google/genai@1.52.0", @@ -79,6 +80,7 @@ "mongodb-v5": "npm:mongodb@^5.0.0", "mongodb-v6": "npm:mongodb@^6.0.0", "mongodb-v7": "npm:mongodb@^7.0.0", + "multer": "^2.1.1", "mysql": "^2.18.1", "mysql2-v3.10": "npm:mysql2@3.10", "mysql2-v3.12": "npm:mysql2@3.12", @@ -2804,6 +2806,23 @@ "fastify-plugin": "^5.0.0" } }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/error": { "version": "4.2.0", "dev": true, @@ -2870,6 +2889,54 @@ "dequal": "^2.0.3" } }, + "node_modules/@fastify/multipart": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.0.0.tgz", + "integrity": "sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, + "node_modules/@fastify/multipart/node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@fastify/multipart/node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/@fastify/proxy-addr": { "version": "5.1.0", "dev": true, @@ -6668,6 +6735,13 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "dev": true, + "license": "MIT" + }, "node_modules/arg": { "version": "4.1.3", "dev": true, @@ -7020,6 +7094,13 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/bunyan": { "version": "1.8.15", "dev": true, @@ -7037,6 +7118,18 @@ "safe-json-stringify": "~1" } }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dev": true, + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/byte-counter": { "version": "0.1.0", "dev": true, @@ -7768,6 +7861,22 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "dev": true, + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "dev": true, @@ -11862,6 +11971,73 @@ "dev": true, "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mv": { "version": "2.1.1", "dev": true, @@ -15966,6 +16142,15 @@ "dev": true, "license": "MIT" }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "dev": true, @@ -16853,6 +17038,13 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "dev": true, diff --git a/library/package.json b/library/package.json index 73b8c50c0..594465974 100644 --- a/library/package.json +++ b/library/package.json @@ -60,6 +60,7 @@ "@aws-sdk/client-bedrock-runtime": "3.929.0", "@clickhouse/client": "^1.7.0", "@fastify/cookie": "^11.0.2", + "@fastify/multipart": "^10.0.0", "@google-cloud/functions-framework": "^5.0.0", "@google-cloud/pubsub": "^5.0.0", "@google/genai-v1": "npm:@google/genai@1.52.0", @@ -73,8 +74,6 @@ "@koa/router-v11": "npm:@koa/router@^11.0.0", "@koa/router-v12": "npm:@koa/router@^12.0.0", "@koa/router-v13": "npm:@koa/router@^13.0.0", - "mistralai-v1": "npm:@mistralai/mistralai@^1.11.0", - "mistralai-v2": "npm:@mistralai/mistralai@^2.1.2", "@prisma/client": "^5.22.0", "@sinonjs/fake-timers": "^11.2.2", "@types/aws-lambda": "^8.10.131", @@ -118,11 +117,14 @@ "koa-v3": "npm:koa@^3.0.0", "mariadb-v3.4": "npm:mariadb@3.4.4", "mariadb-v3.5": "npm:mariadb@^3.5.1", + "mistralai-v1": "npm:@mistralai/mistralai@^1.11.0", + "mistralai-v2": "npm:@mistralai/mistralai@^2.1.2", "mongodb": "^6.16.0", "mongodb-v4": "npm:mongodb@^4.0.0", "mongodb-v5": "npm:mongodb@^5.0.0", "mongodb-v6": "npm:mongodb@^6.0.0", "mongodb-v7": "npm:mongodb@^7.0.0", + "multer": "^2.1.1", "mysql": "^2.18.1", "mysql2-v3.10": "npm:mysql2@3.10", "mysql2-v3.12": "npm:mysql2@3.12", diff --git a/library/sources/Express.tests.ts b/library/sources/Express.tests.ts index e7997143b..237b0c6d7 100644 --- a/library/sources/Express.tests.ts +++ b/library/sources/Express.tests.ts @@ -266,6 +266,17 @@ export async function createExpressTests(expressPackageName: string) { res.send({ success: true }); }); + const multer = require("multer"); + const upload = multer({ storage: multer.memoryStorage() }); + + app.post("/upload-single", upload.single("avatar"), (req, res) => { + res.send(getContext()); + }); + + app.post("/upload-multiple", upload.array("docs", 5), (req, res) => { + res.send(getContext()); + }); + if (expressPackageName.endsWith("v4")) { app.get("/white-listed-ip-address", (req, res, next) => { res.send({ hello: "world" }); @@ -865,4 +876,84 @@ export async function createExpressTests(expressPackageName: string) { }, }); }); + + t.test( + "it captures single file metadata in context when using multer", + async (t) => { + const response = await request(getApp()) + .post("/upload-single") + .attach("avatar", Buffer.from("fake image data"), { + filename: "profile.png", + contentType: "image/png", + }); + + t.same(response.statusCode, 200); + t.match(response.body, { + source: "express", + files: { + fieldname: "avatar", + originalname: "profile.png", + mimetype: "image/png", + encoding: "7bit", + }, + }); + } + ); + + t.test( + "it captures multiple file metadata in context when using multer", + async (t) => { + const response = await request(getApp()) + .post("/upload-multiple") + .attach("docs", Buffer.from("pdf content"), { + filename: "report.pdf", + contentType: "application/pdf", + }) + .attach("docs", Buffer.from("doc content"), { + filename: "summary.docx", + contentType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }); + + t.same(response.statusCode, 200); + t.match(response.body, { + source: "express", + }); + t.same(Array.isArray(response.body.files), true); + t.same(response.body.files.length, 2); + t.match(response.body.files[0], { + fieldname: "docs", + originalname: "report.pdf", + mimetype: "application/pdf", + }); + t.match(response.body.files[1], { + fieldname: "docs", + originalname: "summary.docx", + mimetype: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }); + } + ); + + t.test( + "it captures filename in context.files when using multer", + async (t) => { + const response = await request(getApp()) + .post("/upload-single") + .attach("avatar", Buffer.from("file content"), { + filename: "../../etc/passwd", + contentType: "text/plain", + }); + + t.same(response.statusCode, 200); + t.match(response.body, { + source: "express", + files: { + fieldname: "avatar", + originalname: "passwd", // Already normalized by multer + mimetype: "text/plain", + }, + }); + } + ); } diff --git a/library/sources/HTTPServer.test.ts b/library/sources/HTTPServer.test.ts index 2f053481f..54a8c30c8 100644 --- a/library/sources/HTTPServer.test.ts +++ b/library/sources/HTTPServer.test.ts @@ -1196,7 +1196,7 @@ t.test("It decodes multipart form data and sets body in context", async (t) => { }); }); -t.test("It ignores multipart form data files", async (t) => { +t.test("It captures multipart form data file metadata", async (t) => { // Enables body parsing process.env.NEXT_DEPLOYMENT_ID = ""; @@ -1224,6 +1224,14 @@ t.test("It ignores multipart form data files", async (t) => { { name: "field2", value: { abc: "test", arr: ["c"] } }, ], }); + t.same(context.files, [ + { + fieldname: "file1", + filename: "test.txt", + encoding: "7bit", + mimeType: "text/plain", + }, + ]); server.close(); resolve(); }); @@ -1261,3 +1269,156 @@ t.test("Invalid multipart form data is ignored", async (t) => { }); }); }); + +t.test( + "It captures metadata for multiple files in multipart upload", + async (t) => { + process.env.NEXT_DEPLOYMENT_ID = ""; + + const server = http.createServer((req, res) => { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(getContext())); + }); + + await new Promise((resolve) => { + server.listen(3233, () => { + fetch({ + url: new URL("http://localhost:3233"), + method: "POST", + headers: { + "Content-Type": + "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW", + }, + body: [ + "------WebKitFormBoundary7MA4YWxkTrZu0gW\r\n", + 'Content-Disposition: form-data; name="file1"; filename="report.pdf"\r\n', + "Content-Type: application/pdf\r\n\r\n", + "PDF content here.\r\n", + "------WebKitFormBoundary7MA4YWxkTrZu0gW\r\n", + 'Content-Disposition: form-data; name="file2"; filename="image.jpg"\r\n', + "Content-Type: image/jpeg\r\n\r\n", + "JPEG content here.\r\n", + "------WebKitFormBoundary7MA4YWxkTrZu0gW--", + ].join(""), + timeoutInMS: 500, + }).then(({ body }) => { + const context = JSON.parse(body); + t.same(context.body, undefined); + t.same(context.files, [ + { + fieldname: "file1", + filename: "report.pdf", + encoding: "7bit", + mimeType: "application/pdf", + }, + { + fieldname: "file2", + filename: "image.jpg", + encoding: "7bit", + mimeType: "image/jpeg", + }, + ]); + server.close(); + resolve(); + }); + }); + }); + } +); + +t.test( + "It captures malicious filename in multipart file metadata", + async (t) => { + process.env.NEXT_DEPLOYMENT_ID = ""; + + const server = http.createServer((req, res) => { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(getContext())); + }); + + await new Promise((resolve) => { + server.listen(3234, () => { + fetch({ + url: new URL("http://localhost:3234"), + method: "POST", + headers: { + "Content-Type": + "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW", + }, + body: [ + "------WebKitFormBoundary7MA4YWxkTrZu0gW\r\n", + // SQL injection payload embedded in the filename itself (no path component so busboy preserves it) + "Content-Disposition: form-data; name=\"upload\"; filename=\"' OR '1'='1.txt\"\r\n", + "Content-Type: text/plain\r\n\r\n", + "file content\r\n", + "------WebKitFormBoundary7MA4YWxkTrZu0gW--", + ].join(""), + timeoutInMS: 500, + }).then(({ body }) => { + const context = JSON.parse(body); + t.same(context.files, [ + { + fieldname: "upload", + filename: "' OR '1'='1.txt", + encoding: "7bit", + mimeType: "text/plain", + }, + ]); + server.close(); + resolve(); + }); + }); + }); + } +); + +t.test( + "It captures files and fields together in multipart upload", + async (t) => { + process.env.NEXT_DEPLOYMENT_ID = ""; + + const server = http.createServer((req, res) => { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(getContext())); + }); + + await new Promise((resolve) => { + server.listen(3235, () => { + fetch({ + url: new URL("http://localhost:3235"), + method: "POST", + headers: { + "Content-Type": + "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW", + }, + body: [ + "------WebKitFormBoundary7MA4YWxkTrZu0gW\r\n", + 'Content-Disposition: form-data; name="label"\r\n\r\n', + "my-document\r\n", + "------WebKitFormBoundary7MA4YWxkTrZu0gW\r\n", + 'Content-Disposition: form-data; name="attachment"; filename="doc.pdf"\r\n', + "Content-Type: application/pdf\r\n\r\n", + "PDF bytes.\r\n", + "------WebKitFormBoundary7MA4YWxkTrZu0gW--", + ].join(""), + timeoutInMS: 500, + }).then(({ body }) => { + const context = JSON.parse(body); + t.same(context.body, { + fields: [{ name: "label", value: "my-document" }], + }); + t.same(context.files, [ + { + fieldname: "attachment", + filename: "doc.pdf", + encoding: "7bit", + mimeType: "application/pdf", + }, + ]); + server.close(); + resolve(); + }); + }); + }); + } +); diff --git a/library/sources/express/contextFromRequest.ts b/library/sources/express/contextFromRequest.ts index 2a89ccb79..157f3a130 100644 --- a/library/sources/express/contextFromRequest.ts +++ b/library/sources/express/contextFromRequest.ts @@ -3,7 +3,9 @@ import { Context } from "../../agent/Context"; import { buildRouteFromURL } from "../../helpers/buildRouteFromURL"; import { getIPAddressFromRequest } from "../../helpers/getIPAddressFromRequest"; -export function contextFromRequest(req: Request): Context { +export function contextFromRequest( + req: Request & { files?: unknown; file?: unknown } +): Context { const url = req.protocol + "://" + req.get("host") + req.originalUrl; return { @@ -22,5 +24,6 @@ export function contextFromRequest(req: Request): Context { source: "express", route: buildRouteFromURL(url), subdomains: req.subdomains, + files: req.files ?? req.file, }; } diff --git a/library/sources/http-server/contextFromRequest.ts b/library/sources/http-server/contextFromRequest.ts index 6b7087dc1..840717356 100644 --- a/library/sources/http-server/contextFromRequest.ts +++ b/library/sources/http-server/contextFromRequest.ts @@ -8,6 +8,7 @@ import { tryParseURLParams } from "../../helpers/tryParseURLParams"; export function contextFromRequest( req: IncomingMessage, body: unknown, + files: unknown, module: string ): Context { const queryObject: Record = {}; @@ -28,6 +29,7 @@ export function contextFromRequest( routeParams: {}, cookies: req.headers?.cookie ? parse(req.headers.cookie) : {}, body: body ? body : undefined, + files: files, remoteAddress: getIPAddressFromRequest({ headers: req.headers, remoteAddress: req.socket?.remoteAddress, diff --git a/library/sources/http-server/createRequestListener.ts b/library/sources/http-server/createRequestListener.ts index 9e28dfbc6..cd8fab419 100644 --- a/library/sources/http-server/createRequestListener.ts +++ b/library/sources/http-server/createRequestListener.ts @@ -28,7 +28,15 @@ export function createRequestListener( const readBody = "NEXT_DEPLOYMENT_ID" in process.env || isMicroInstalled; if (!readBody) { - return callListenerWithContext(listener, req, res, module, agent, ""); + return callListenerWithContext( + listener, + req, + res, + module, + agent, + "", + undefined + ); } const result = await readBodyStream(req, res, agent); @@ -43,7 +51,8 @@ export function createRequestListener( res, module, agent, - result.body + result.body, + result.files ); }; } @@ -57,9 +66,10 @@ function callListenerWithContext( res: ServerResponse, module: string, agent: Agent, - body: unknown + body: unknown, + files: unknown ) { - const context = contextFromRequest(req, body, module); + const context = contextFromRequest(req, body, files, module); return runWithContext(context, () => { const context = getContext(); diff --git a/library/sources/http-server/readBodyStream.ts b/library/sources/http-server/readBodyStream.ts index ef8932c03..5a6d452ef 100644 --- a/library/sources/http-server/readBodyStream.ts +++ b/library/sources/http-server/readBodyStream.ts @@ -9,10 +9,18 @@ import { getBodyDataType } from "../../agent/api-discovery/getBodyDataType"; import { tryParseJSON } from "../../helpers/tryParseJSON"; import { getInstance } from "../../agent/AgentSingleton"; +type BodyFile = { + fieldname: string; + filename: string; + encoding: string; + mimeType: string; +}; + type BodyReadResult = | { success: true; body: unknown; + files?: BodyFile[]; } | { success: false; @@ -25,6 +33,7 @@ export async function readBodyStream( ): Promise { let bodyText = ""; let bodyFields: { name: string; value: unknown }[] = []; + const bodyFiles: BodyFile[] = []; let bodySize = 0; const maxBodySize = getMaxBodySize(); const stream = new PassThrough(); @@ -59,6 +68,22 @@ export async function readBodyStream( bodyFields.push({ name: fieldname, value: val }); }); + + busboy.on( + "file", + (fieldname, fileStream, filename, encoding, mimeType) => { + bodyFiles.push({ + fieldname: typeof fieldname === "string" ? fieldname : "", + filename: typeof filename === "string" ? filename : "", + encoding: typeof encoding === "string" ? encoding : "", + mimeType: typeof mimeType === "string" ? mimeType : "", + }); + // Drain the file stream so busboy doesn't stall waiting for a + // consumer. We deliberately do not buffer the contents — only the + // metadata is needed for injection detection on filename/mimetype. + fileStream.resume(); + } + ); } } try { @@ -106,12 +131,11 @@ export async function readBodyStream( // Ensure the body stream can be read again by the application replaceRequestBody(req, stream); - if (bodyFields.length > 0) { + if (bodyFields.length > 0 || bodyFiles.length > 0) { return { success: true, - body: { - fields: bodyFields, - }, + body: bodyFields.length > 0 ? { fields: bodyFields } : undefined, + files: bodyFiles.length > 0 ? bodyFiles : undefined, }; } diff --git a/library/vulnerabilities/path-traversal/checkContextForPathTraversal.test.ts b/library/vulnerabilities/path-traversal/checkContextForPathTraversal.test.ts index b6c1a9357..10fe6357f 100644 --- a/library/vulnerabilities/path-traversal/checkContextForPathTraversal.test.ts +++ b/library/vulnerabilities/path-traversal/checkContextForPathTraversal.test.ts @@ -460,3 +460,120 @@ t.test( ); } ); + +t.test( + "it detects path traversal from multipart file filename metadata", + async () => { + t.same( + checkContextForPathTraversal({ + filename: "/uploads/../../etc/passwd", + operation: "fs.readFile", + context: { + cookies: {}, + headers: {}, + remoteAddress: "ip", + method: "POST", + url: "url", + query: {}, + body: undefined, + files: [ + { + fieldname: "upload", + filename: "../../etc/passwd", + encoding: "7bit", + mimeType: "text/plain", + }, + ], + routeParams: {}, + source: "express", + route: "/upload", + }, + }), + { + operation: "fs.readFile", + kind: "path_traversal", + source: "files", + pathsToPayload: [".[0].filename"], + metadata: { + filename: "/uploads/../../etc/passwd", + }, + payload: "../../etc/passwd", + } + ); + } +); + +t.test( + "it detects path traversal from originalname field (multer-style file metadata)", + async () => { + t.same( + checkContextForPathTraversal({ + filename: "/uploads/../../var/www/shell.php", + operation: "fs.writeFile", + context: { + cookies: {}, + headers: {}, + remoteAddress: "ip", + method: "POST", + url: "url", + query: {}, + body: undefined, + files: [ + { + fieldname: "avatar", + originalname: "../../var/www/shell.php", + encoding: "7bit", + mimetype: "image/jpeg", + }, + ], + routeParams: {}, + source: "express", + route: "/profile", + }, + }), + { + operation: "fs.writeFile", + kind: "path_traversal", + source: "files", + pathsToPayload: [".[0].originalname"], + metadata: { + filename: "/uploads/../../var/www/shell.php", + }, + payload: "../../var/www/shell.php", + } + ); + } +); + +t.test( + "it does not flag path traversal when file metadata is benign", + async () => { + t.same( + checkContextForPathTraversal({ + filename: "/uploads/report.pdf", + operation: "fs.readFile", + context: { + cookies: {}, + headers: {}, + remoteAddress: "ip", + method: "POST", + url: "url", + query: {}, + body: undefined, + files: [ + { + fieldname: "document", + filename: "report.pdf", + encoding: "7bit", + mimeType: "application/pdf", + }, + ], + routeParams: {}, + source: "express", + route: "/upload", + }, + }), + undefined + ); + } +); diff --git a/library/vulnerabilities/sql-injection/checkContextForSqlInjection.test.ts b/library/vulnerabilities/sql-injection/checkContextForSqlInjection.test.ts index 79f90ac3e..bef6c24a7 100644 --- a/library/vulnerabilities/sql-injection/checkContextForSqlInjection.test.ts +++ b/library/vulnerabilities/sql-injection/checkContextForSqlInjection.test.ts @@ -102,3 +102,44 @@ t.test( ); } ); + +t.test("it detects SQL injection in multipart file metadata", async () => { + t.same( + checkContextForSqlInjection({ + sql: "SELECT id, label FROM documents WHERE label = '' OR '1'='1'", + operation: "mysql.query", + dialect: new SQLDialectMySQL(), + context: { + cookies: {}, + headers: {}, + remoteAddress: "ip", + method: "POST", + url: "url", + query: {}, + body: undefined, + files: [ + { + fieldname: "document", + originalname: "' OR '1'='1", + encoding: "7bit", + mimetype: "application/pdf", + }, + ], + source: "express", + route: "/", + routeParams: {}, + }, + }), + { + operation: "mysql.query", + kind: "sql_injection", + source: "files", + pathsToPayload: [".[0].originalname"], + metadata: { + sql: "SELECT id, label FROM documents WHERE label = '' OR '1'='1'", + dialect: "MySQL", + }, + payload: "' OR '1'='1", + } + ); +}); From 414b347c233238d7f550e803cc56d240d759d01b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 29 May 2026 11:32:42 +0200 Subject: [PATCH 2/2] Add file parsing for hono --- library/package-lock.json | 66 --------- library/package.json | 1 - library/sources/Express.tests.ts | 3 +- library/sources/Hono.test.ts | 133 ++++++++++++++++++ library/sources/hono/contextFromRequest.ts | 7 +- .../sources/hono/wrapRequestBodyParsing.ts | 37 +++++ 6 files changed, 178 insertions(+), 69 deletions(-) diff --git a/library/package-lock.json b/library/package-lock.json index 8907be79f..d684fd5d5 100644 --- a/library/package-lock.json +++ b/library/package-lock.json @@ -16,7 +16,6 @@ "@aws-sdk/client-bedrock-runtime": "3.929.0", "@clickhouse/client": "^1.7.0", "@fastify/cookie": "^11.0.2", - "@fastify/multipart": "^10.0.0", "@google-cloud/functions-framework": "^5.0.0", "@google-cloud/pubsub": "^5.0.0", "@google/genai-v1": "npm:@google/genai@1.52.0", @@ -2806,23 +2805,6 @@ "fastify-plugin": "^5.0.0" } }, - "node_modules/@fastify/deepmerge": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", - "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@fastify/error": { "version": "4.2.0", "dev": true, @@ -2889,54 +2871,6 @@ "dequal": "^2.0.3" } }, - "node_modules/@fastify/multipart": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.0.0.tgz", - "integrity": "sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/busboy": "^3.0.0", - "@fastify/deepmerge": "^3.0.0", - "@fastify/error": "^4.0.0", - "fastify-plugin": "^5.0.0", - "secure-json-parse": "^4.0.0" - } - }, - "node_modules/@fastify/multipart/node_modules/@fastify/busboy": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", - "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@fastify/multipart/node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/@fastify/proxy-addr": { "version": "5.1.0", "dev": true, diff --git a/library/package.json b/library/package.json index 594465974..4f141ee16 100644 --- a/library/package.json +++ b/library/package.json @@ -60,7 +60,6 @@ "@aws-sdk/client-bedrock-runtime": "3.929.0", "@clickhouse/client": "^1.7.0", "@fastify/cookie": "^11.0.2", - "@fastify/multipart": "^10.0.0", "@google-cloud/functions-framework": "^5.0.0", "@google-cloud/pubsub": "^5.0.0", "@google/genai-v1": "npm:@google/genai@1.52.0", diff --git a/library/sources/Express.tests.ts b/library/sources/Express.tests.ts index 237b0c6d7..a192370c1 100644 --- a/library/sources/Express.tests.ts +++ b/library/sources/Express.tests.ts @@ -76,10 +76,12 @@ export async function createExpressTests(expressPackageName: string) { }); let express = require(expressPackageName) as typeof import("express"); + let multer = require("multer"); if (isEsmUnitTest()) { // @ts-expect-error Wrong types express = express.default; + multer = multer.default; } const { readFile, readdir } = require("fs") as typeof import("fs"); @@ -266,7 +268,6 @@ export async function createExpressTests(expressPackageName: string) { res.send({ success: true }); }); - const multer = require("multer"); const upload = multer({ storage: multer.memoryStorage() }); app.post("/upload-single", upload.single("avatar"), (req, res) => { diff --git a/library/sources/Hono.test.ts b/library/sources/Hono.test.ts index 7fed9b9d6..6ef403535 100644 --- a/library/sources/Hono.test.ts +++ b/library/sources/Hono.test.ts @@ -143,6 +143,30 @@ async function getApp() { return c.json(getContext()); }); + app.post("/upload", async (c) => { + const body = await c.req.parseBody({ all: true }); + const value = body["file"]; + + const files = Array.isArray(value) + ? value.filter((item): item is File => item instanceof File) + : value instanceof File + ? [value] + : []; + + if (files.length === 0) { + return c.text("At least one file is required", 400); + } + + return c.json({ + context: getContext(), + files: files.map((file) => ({ + name: file.name, + size: file.size, + type: file.type, + })), + }); + }); + app.on(["GET"], ["/user", "/user/blocked"], (c) => { return c.json(getContext()); }); @@ -712,3 +736,112 @@ t.test("it does not rate limit excluded users", opts, async (t) => { t.match(await response.text(), "OK"); } }); + +t.test("it captures single file metadata in context.files", opts, async (t) => { + const app = await getApp(); + + const formData = new FormData(); + formData.append( + "file", + new File(["fake image bytes"], "profile.png", { type: "image/png" }) + ); + + const response = await app.request("/upload", { + method: "POST", + body: formData, + }); + + t.same(response.status, 200); + const body = await response.json(); + t.same(body.files.length, 1); + t.match(body.files[0], { name: "profile.png", type: "image/png" }); + t.same(Array.isArray(body.context.files), true); + t.same(body.context.files.length, 1); + t.match(body.context.files[0], { + fieldname: "file", + filename: "profile.png", + mimetype: "image/png", + }); +}); + +t.test( + "it captures multiple file metadata in context.files", + opts, + async (t) => { + const app = await getApp(); + + const formData = new FormData(); + formData.append( + "file", + new File(["pdf content"], "report.pdf", { type: "application/pdf" }) + ); + formData.append( + "file", + new File(["doc content"], "summary.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }) + ); + + const response = await app.request("/upload", { + method: "POST", + body: formData, + }); + + t.same(response.status, 200); + const body = await response.json(); + t.same(body.files.length, 2); + t.same(Array.isArray(body.context.files), true); + t.same(body.context.files.length, 2); + t.match(body.context.files[0], { + fieldname: "file", + filename: "report.pdf", + mimetype: "application/pdf", + }); + t.match(body.context.files[1], { + fieldname: "file", + filename: "summary.docx", + mimetype: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }); + } +); + +t.test("it captures malicious filename in context.files", opts, async (t) => { + const app = await getApp(); + + const formData = new FormData(); + formData.append( + "file", + new File(["file content"], "' OR '1'='1.txt", { type: "text/plain" }) + ); + + const response = await app.request("/upload", { + method: "POST", + body: formData, + }); + + t.same(response.status, 200); + const body = await response.json(); + t.same(Array.isArray(body.context.files), true); + t.match(body.context.files[0], { + fieldname: "file", + filename: "' OR '1'='1.txt", + mimetype: "text/plain", + }); +}); + +t.test( + "it does not set context.files when no files are uploaded", + opts, + async (t) => { + const app = await getApp(); + const response = await app.request("/form", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "title=test", + }); + + const body = await response.json(); + t.same(body.files, undefined); + } +); diff --git a/library/sources/hono/contextFromRequest.ts b/library/sources/hono/contextFromRequest.ts index 27dfa56c4..a7d9d7013 100644 --- a/library/sources/hono/contextFromRequest.ts +++ b/library/sources/hono/contextFromRequest.ts @@ -17,11 +17,16 @@ export function contextFromRequest(c: HonoContext): Context { headers: req.header(), remoteAddress: getRemoteAddress(c), }), - // Pass the body from the existing context if it's already set, otherwise the body is set in wrapRequestBodyParsing + // Pass body and files from the existing context if already set, + // otherwise they are populated in wrapRequestBodyParsing after parseBody() is called. body: existingContext && existingContext.source === "hono" ? existingContext.body : undefined, + files: + existingContext && existingContext.source === "hono" + ? existingContext.files + : undefined, url: req.url, headers: req.header(), routeParams: req.param(), diff --git a/library/sources/hono/wrapRequestBodyParsing.ts b/library/sources/hono/wrapRequestBodyParsing.ts index 356750289..3dcd3d8b8 100644 --- a/library/sources/hono/wrapRequestBodyParsing.ts +++ b/library/sources/hono/wrapRequestBodyParsing.ts @@ -9,6 +9,38 @@ export function wrapRequestBodyParsing(req: Context["req"]) { req.text = wrapBodyParsingFunction(req.text); } +type FileInfo = { fieldname: string; filename: string; mimetype: string }; + +function extractFilesFromBody(body: unknown): FileInfo[] { + const files: FileInfo[] = []; + + if (typeof body !== "object" || body === null) { + return files; + } + + for (const [key, value] of Object.entries(body)) { + if (typeof File !== "undefined" && value instanceof File) { + files.push({ + fieldname: key, + filename: value.name, + mimetype: value.type, + }); + } else if (Array.isArray(value)) { + for (const item of value) { + if (typeof File !== "undefined" && item instanceof File) { + files.push({ + fieldname: key, + filename: item.name, + mimetype: item.type, + }); + } + } + } + } + + return files; +} + function wrapBodyParsingFunction(func: T) { if (isWrapped(func)) { return func; @@ -23,6 +55,11 @@ function wrapBodyParsingFunction(func: T) { const context = getContext(); if (context) { updateContext(context, "body", returnValue); + + const files = extractFilesFromBody(returnValue); + if (files.length > 0) { + updateContext(context, "files", files); + } } }