diff --git a/README.md b/README.md index 18f73935c..c7cf5a3ab 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Zen for Node.js 16+ is compatible with: ### Database drivers - ✅ [`mongodb`](https://www.npmjs.com/package/mongodb) 4.x, 5.x, 6.x and 7.x _(npm package versions, not MongoDB server versions)_ -- ✅ [`mongoose`](https://www.npmjs.com/package/mongoose) 8.x, 7.x and 6.x +- ✅ [`mongoose`](https://www.npmjs.com/package/mongoose) 9.x, 8.x, 7.x and 6.x - ✅ [`pg`](https://www.npmjs.com/package/pg) 8.x and 7.x - ✅ [`mysql`](https://www.npmjs.com/package/mysql) 2.x - ✅ [`mysql2`](https://www.npmjs.com/package/mysql2) 3.x diff --git a/end2end/tests/express-mongoose.test.js b/end2end/tests/express-mongoose.test.js index 4ea1d06fc..98322de71 100644 --- a/end2end/tests/express-mongoose.test.js +++ b/end2end/tests/express-mongoose.test.js @@ -39,13 +39,17 @@ t.test("it blocks in blocking mode", (t) => { fetch("http://localhost:4000/?search[$ne]=null", { signal: AbortSignal.timeout(5000), }), + fetch("http://localhost:4000/?search[$nin]=foo", { + signal: AbortSignal.timeout(5000), + }), fetch("http://localhost:4000/?search=title", { signal: AbortSignal.timeout(5000), }), ]); }) - .then(([noSQLInjection, normalSearch]) => { + .then(([noSQLInjection, noSQLInjection2, normalSearch]) => { t.equal(noSQLInjection.status, 500); + t.equal(noSQLInjection2.status, 500); t.equal(normalSearch.status, 200); t.match(stdout, /Starting agent/); t.match(stderr, /Zen has blocked a NoSQL injection/); @@ -84,13 +88,17 @@ t.test("it does not block in dry mode", (t) => { fetch("http://localhost:4001/?search[$ne]=null", { signal: AbortSignal.timeout(5000), }), + fetch("http://localhost:4001/?search[$nin]=foo", { + signal: AbortSignal.timeout(5000), + }), fetch("http://localhost:4001/?search=title", { signal: AbortSignal.timeout(5000), }), ]) ) - .then(([noSQLInjection, normalSearch]) => { + .then(([noSQLInjection, noSQLInjection2, normalSearch]) => { t.equal(noSQLInjection.status, 200); + t.equal(noSQLInjection2.status, 200); t.equal(normalSearch.status, 200); t.match(stdout, /Starting agent/); t.notMatch(stderr, /Zen has blocked a NoSQL injection/); diff --git a/library/agent/Context.ts b/library/agent/Context.ts index 378aa96cb..6dc4d9b5c 100644 --- a/library/agent/Context.ts +++ b/library/agent/Context.ts @@ -37,6 +37,12 @@ export type Context = { rateLimitGroup?: string; // Used to apply rate limits to a group of users rateLimitedEndpoint?: Endpoint; // The route that was rate limited tenantId?: string; // Used for IDOR protection - set via setTenantId() + + /** + * Used to store the original, not normalized filter for some NoSQL libraries, e.g. mongoose, + * as we can not match the normalized filter with the payload in some cases + */ + notNormalizedNoSqlFilter?: unknown; }; /** diff --git a/library/agent/protect.ts b/library/agent/protect.ts index 692f6a16d..32e10d3b4 100644 --- a/library/agent/protect.ts +++ b/library/agent/protect.ts @@ -61,6 +61,7 @@ import { FunctionSink } from "../sinks/FunctionSink"; import type { FetchListsAPI } from "./api/FetchListsAPI"; import { FetchListsAPINodeHTTP } from "./api/FetchListsAPINodeHTTP"; import shouldEnableFirewall from "../helpers/shouldEnableFirewall"; +import { Mongoose } from "../sinks/Mongoose"; function getLogger(): Logger { if (isDebugging()) { @@ -175,6 +176,7 @@ export function getWrappers() { new AwsSDKVersion2(), new AiSDK(), new GoogleGenAi(), + new Mongoose(), ]; } diff --git a/library/helpers/clone.test.ts b/library/helpers/clone.test.ts new file mode 100644 index 000000000..4743c95a0 --- /dev/null +++ b/library/helpers/clone.test.ts @@ -0,0 +1,17 @@ +import * as t from "tap"; +import { clone } from "./clone"; + +t.test("it clones objects", async (t) => { + const obj = { a: 1, b: { c: 2 } }; + const cloned = clone(obj); + t.equal(cloned.a, 1); + t.equal(cloned.b.c, 2); + + // Modifying the cloned object should not affect the original + cloned.b.c = 3; + t.equal(obj.b.c, 2); + + // Modifing the original object should not affect the cloned + obj.b.c = 4; + t.equal(cloned.b.c, 3); +}); diff --git a/library/helpers/clone.ts b/library/helpers/clone.ts new file mode 100644 index 000000000..6fcc4bb9f --- /dev/null +++ b/library/helpers/clone.ts @@ -0,0 +1,9 @@ +// Node.js v16 does not have structuredClone, so we need to use a polyfill +const cloneFunction = + typeof structuredClone === "function" + ? structuredClone + : (obj: any) => JSON.parse(JSON.stringify(obj)); + +export function clone(obj: T): T { + return cloneFunction(obj); +} diff --git a/library/package-lock.json b/library/package-lock.json index 003252a6d..297baaee7 100644 --- a/library/package-lock.json +++ b/library/package-lock.json @@ -78,6 +78,7 @@ "mongodb-v5": "npm:mongodb@^5.0.0", "mongodb-v6": "npm:mongodb@^6.0.0", "mongodb-v7": "npm:mongodb@^7.0.0", + "mongoose": "^9.3.0", "mysql": "^2.18.1", "mysql2-v3.10": "npm:mysql2@3.10", "mysql2-v3.12": "npm:mysql2@3.12", @@ -10496,6 +10497,16 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kareem": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz", + "integrity": "sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/keygrip": { "version": "1.1.0", "dev": true, @@ -11899,6 +11910,129 @@ "node": ">=20.19.0" } }, + "node_modules/mongoose": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.3.0.tgz", + "integrity": "sha512-Tv2p3DLBkftoGFp+VM/19k0t0RYPAAYjGIbCVGlV6Tf5Dnq6TICfYyeKeYvwQ06nK9sRDvymP3B+tjGHnUlaxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "kareem": "3.2.0", + "mongodb": "~7.1", + "mpath": "0.9.0", + "mquery": "6.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/mongoose/node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongoose/node_modules/mongodb": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz", + "integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.1.1", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongoose/node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", + "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/ms": { "version": "2.1.3", "dev": true, @@ -15528,6 +15662,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/signal-exit": { "version": "4.1.0", "dev": true, diff --git a/library/package.json b/library/package.json index d74e555ac..ca93912af 100644 --- a/library/package.json +++ b/library/package.json @@ -122,6 +122,7 @@ "mongodb-v5": "npm:mongodb@^5.0.0", "mongodb-v6": "npm:mongodb@^6.0.0", "mongodb-v7": "npm:mongodb@^7.0.0", + "mongoose": "^9.3.0", "mysql": "^2.18.1", "mysql2-v3.10": "npm:mysql2@3.10", "mysql2-v3.12": "npm:mysql2@3.12", diff --git a/library/sinks/MongoDB.ts b/library/sinks/MongoDB.ts index 5287da658..abdea8249 100644 --- a/library/sinks/MongoDB.ts +++ b/library/sinks/MongoDB.ts @@ -43,11 +43,18 @@ export class MongoDB implements Wrapper { private inspectFilter( db: string, collection: string, - request: Context, + context: Context, filter: unknown, operation: string ): InterceptorResult { - const result = detectNoSQLInjection(request, filter); + let result = detectNoSQLInjection(context, filter); + + if (!result.injection && context.notNormalizedNoSqlFilter) { + // Also check the original, not normalized filter in the context, if set + // Mongoose modifies the filter object in-place and we might not be able to match the normalized filter + // with the payload, so we need to check the original filter as well + result = detectNoSQLInjection(context, context.notNormalizedNoSqlFilter); + } if (result.injection) { return { diff --git a/library/sinks/Mongoose.test.ts b/library/sinks/Mongoose.test.ts new file mode 100644 index 000000000..bd43a358e --- /dev/null +++ b/library/sinks/Mongoose.test.ts @@ -0,0 +1,173 @@ +import * as t from "tap"; +import { Mongoose as MongooseSink } from "./Mongoose"; +import { startTestAgent } from "../helpers/startTestAgent"; +import { getContext, runWithContext, type Context } from "../agent/Context"; +import { MongoDB as MongoDBSink } from "./MongoDB"; + +const dbUrl = + "mongodb://root:password@127.0.0.1:27020/mongoose?authSource=admin&directConnection=true"; + +function getTestContext(body: unknown): Context { + return { + remoteAddress: "::1", + method: "POST", + url: "http://localhost:4000", + query: {}, + headers: {}, + body: body, + cookies: {}, + routeParams: {}, + source: "hono", + route: "/posts/:id", + }; +} + +t.test("it works", async (t) => { + startTestAgent({ + block: true, + wrappers: [new MongooseSink(), new MongoDBSink()], + rewrite: {}, + }); + + const mongoose = require("mongoose") as typeof import("mongoose"); + + await mongoose.connect(dbUrl); + + try { + const Schema = mongoose.Schema; + const ObjectId = Schema.ObjectId; + + const BlogPostSchema = new Schema({ + author: ObjectId, + title: String, + body: String, + date: Date, + }); + + const BlogPost = mongoose.model("BlogPost", BlogPostSchema); + + const post = new BlogPost({ + title: "Test", + body: "This is a test", + date: new Date(), + }); + await post.save(); + + // @ts-expect-error Pass string instead of array + const foundPost = await BlogPost.findOne({ title: { $in: "Test" } }); + t.match(foundPost?.title, "Test"); + t.match(foundPost?.body, "This is a test"); + + await runWithContext(getTestContext({ foo: "bar" }), async () => { + // @ts-expect-error Pass string instead of array + const foundPost2 = await BlogPost.findOne({ title: { $in: "Test" } }); + t.match(foundPost2?.title, "Test"); + t.match(foundPost2?.body, "This is a test"); + + t.same(getContext()?.notNormalizedNoSqlFilter, { + title: { $in: "Test" }, + }); + }); + + await runWithContext( + getTestContext({ title: { $in: "Test" } }), + async () => { + const error = await t.rejects(async () => { + // @ts-expect-error Pass string instead of array + await BlogPost.findOne({ title: { $in: "Test" } }); + }); + t.ok(error instanceof Error); + if (error instanceof Error) { + t.match( + error.message, + "Zen has blocked a NoSQL injection: MongoDB.Collection.findOne(...) originating from body.title" + ); + } + } + ); + + await runWithContext( + getTestContext({ title: { $nin: "Test" } }), + async () => { + const error = await t.rejects(async () => { + await BlogPost.find({ + // @ts-expect-error Pass string instead of array + title: { $nin: "Test" }, + }); + }); + t.ok(error instanceof Error); + if (error instanceof Error) { + t.match( + error.message, + "Zen has blocked a NoSQL injection: MongoDB.Collection.find(...) originating from body.title" + ); + } + } + ); + + await runWithContext( + getTestContext({ title: { $all: "Test" } }), + async () => { + const error = await t.rejects(async () => { + await BlogPost.find({ + // @ts-expect-error Pass string instead of array + title: { $all: "Test" }, + }); + }); + t.ok(error instanceof Error); + if (error instanceof Error) { + t.match( + error.message, + "Zen has blocked a NoSQL injection: MongoDB.Collection.find(...) originating from body.title" + ); + } + } + ); + + await runWithContext( + getTestContext({ title: { $all: ["Test1", "Test2"] } }), + async () => { + const error = await t.rejects(async () => { + await BlogPost.find({ + title: { $all: ["Test1", "Test2"] }, + }); + }); + t.ok(error instanceof Error); + if (error instanceof Error) { + t.match( + error.message, + "Zen has blocked a NoSQL injection: MongoDB.Collection.find(...) originating from body.title" + ); + } + } + ); + + // Does not throw because context does not match + await runWithContext(getTestContext({ title: "123" }), async () => { + await BlogPost.find({ + // @ts-expect-error Pass string instead of array + title: { $all: "Test" }, + }); + }); + + await runWithContext(getTestContext({ $exists: "true" }), async () => { + const error = await t.rejects(async () => { + await BlogPost.find({ + // @ts-expect-error Pass string instead of boolean + title: { $exists: "true" }, + }); + }); + t.ok(error instanceof Error); + if (error instanceof Error) { + t.match( + error.message, + "Zen has blocked a NoSQL injection: MongoDB.Collection.find(...) originating from body" + ); + } + }); + } catch (err: any) { + t.fail(err); + } finally { + await mongoose.disconnect(); + } +}); diff --git a/library/sinks/Mongoose.ts b/library/sinks/Mongoose.ts new file mode 100644 index 000000000..67cca3671 --- /dev/null +++ b/library/sinks/Mongoose.ts @@ -0,0 +1,50 @@ +import { getContext, updateContext } from "../agent/Context"; +import type { Hooks } from "../agent/hooks/Hooks"; +import { wrapExport } from "../agent/hooks/wrapExport"; +import { Wrapper } from "../agent/Wrapper"; +import { clone } from "../helpers/clone"; + +export class Mongoose implements Wrapper { + #inspectFilter(args: unknown[]): void { + const context = getContext(); + + if ( + args.length <= 1 || + !context || + !args[1] || + typeof args[1] !== "object" + ) { + return; + } + + // We need to clone the filter because mongoose modifies it in place + const filter = clone(args[1]); + + // Save the original, not normalized filter in the context, as we might not be able to match the normalized filter with the payload + // It is then also checked in the MongoDB sink when we inspect the filter + updateContext(context, "notNormalizedNoSqlFilter", filter); + } + + wrap(hooks: Hooks) { + hooks + .addPackage("mongoose") + .withVersion("^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0") + .onFileRequire("lib/cast.js", (exports, pkgInfo) => { + return wrapExport(exports, undefined, pkgInfo, { + kind: undefined, // Not using nosql_op since we wrap MongoDB driver itself + inspectArgs: (args) => this.#inspectFilter(args), + }); + }) + .addFileInstrumentation({ + path: "lib/cast.js", + functions: [ + { + name: "cast", + nodeType: "FunctionExpression", + operationKind: undefined, // Not using nosql_op since we wrap MongoDB driver itself + inspectArgs: (args) => this.#inspectFilter(args), + }, + ], + }); + } +}