diff --git a/library/agent/hooks/Package.ts b/library/agent/hooks/Package.ts index 42dd5f85b..0410c4855 100644 --- a/library/agent/hooks/Package.ts +++ b/library/agent/hooks/Package.ts @@ -12,8 +12,12 @@ type PackageName = string; export class Package { private versions: VersionedPackage[] = []; - constructor(private readonly packageName: PackageName) { - if (!this.packageName) { + constructor(private packageName: PackageName) { + this.assertValidPackageName(this.packageName); + } + + private assertValidPackageName(name: string) { + if (!name) { throw new Error("Package name is required"); } } @@ -22,6 +26,11 @@ export class Package { return this.packageName; } + setName(name: string) { + this.assertValidPackageName(name); + this.packageName = name; + } + withVersion(range: string): VersionedPackage { const pkg = new VersionedPackage(range); this.versions.push(pkg); diff --git a/library/agent/hooks/wrapRequire.ts b/library/agent/hooks/wrapRequire.ts index d8b90c402..c8a78cd82 100644 --- a/library/agent/hooks/wrapRequire.ts +++ b/library/agent/hooks/wrapRequire.ts @@ -305,3 +305,29 @@ function executeInterceptors( export function getOriginalRequire() { return originalRequire; } + +// In order to support multiple versions of the same package, we need to rewrite the package name +// e.g. In our sources and sinks, we use the real package name `hooks.addPackage("undici")` +// but in the tests we want to `require("undici-v6")` instead of `require("undici")` +export function __internalRewritePackageName( + packageName: string, + aliasForTesting: string +) { + if (!isRequireWrapped) { + throw new Error( + "Start the agent before calling __internalRewritePackageName(..)" + ); + } + + if (packages.length === 0) { + throw new Error("No packages to patch"); + } + + const pkg = packages.find((pkg) => pkg.getName() === packageName); + + if (!pkg) { + throw new Error(`Could not find package ${packageName}`); + } + + pkg.setName(aliasForTesting); +} diff --git a/library/helpers/startTestAgent.ts b/library/helpers/startTestAgent.ts new file mode 100644 index 000000000..a16483e02 --- /dev/null +++ b/library/helpers/startTestAgent.ts @@ -0,0 +1,35 @@ +import type { ReportingAPI } from "../agent/api/ReportingAPI"; +import type { Token } from "../agent/api/Token"; +import { __internalRewritePackageName } from "../agent/hooks/wrapRequire"; +import type { Logger } from "../agent/logger/Logger"; +import { Wrapper } from "../agent/Wrapper"; +import { createTestAgent } from "./createTestAgent"; + +type PackageName = string; +type AliasToRequire = string; + +/** + * Start a test agent for testing purposes + */ +export function startTestAgent(opts: { + block?: boolean; + logger?: Logger; + api?: ReportingAPI; + token?: Token; + serverless?: string; + wrappers: Wrapper[]; + rewrite: Record; +}) { + const agent = createTestAgent(opts); + agent.start(opts.wrappers); + + // In order to support multiple versions of the same package, we need to rewrite the package name + // e.g. In our sources and sinks, we use the real package name `hooks.addPackage("undici")` + // but in the tests we want to `require("undici-v6")` instead of `require("undici")` + // The `__internalRewritePackageName` function allows us to do this + Object.keys(opts.rewrite).forEach((packageName) => { + __internalRewritePackageName(packageName, opts.rewrite[packageName]); + }); + + return agent; +} diff --git a/library/package-lock.json b/library/package-lock.json index 310151db2..96c710508 100644 --- a/library/package-lock.json +++ b/library/package-lock.json @@ -56,7 +56,7 @@ "koa": "^2.15.3", "koa-router": "^12.0.1", "mariadb": "^3.3.2", - "mongodb": "6.9", + "mongodb": "~6.9", "mysql": "^2.18.1", "mysql2": "^3.10.0", "needle": "^3.3.1", @@ -72,7 +72,10 @@ "tap": "^18.6.1", "type-fest": "^4.24.0", "typescript": "^5.3.3", - "undici": "^6.12.0", + "undici-v4": "npm:undici@^4.0.0", + "undici-v5": "npm:undici@^5.0.0", + "undici-v6": "npm:undici@^6.0.0", + "undici-v7": "npm:undici@^7.0.0", "xml-js": "^1.6.11", "xml2js": "^0.6.2" }, @@ -333,6 +336,15 @@ } } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@fastify/cookie": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-10.0.1.tgz", @@ -14372,22 +14384,55 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/undici": { + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-v4": { + "name": "undici", + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-4.16.0.tgz", + "integrity": "sha512-tkZSECUYi+/T1i4u+4+lwZmQgLXd4BLGlrc7KZPcLIW7Jpq99+Xpc30ONv7nS6F5UNOxp/HBZSSL9MafUrvJbw==", + "dev": true, + "engines": { + "node": ">=12.18" + } + }, + "node_modules/undici-v5": { + "name": "undici", + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "dev": true, + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-v6": { + "name": "undici", "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz", "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==", "dev": true, - "license": "MIT", "engines": { "node": ">=18.17" } }, - "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "node_modules/undici-v7": { + "name": "undici", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.0.0.tgz", + "integrity": "sha512-c4xi3kWnQJrb7h2q8aJYKvUzmz7boCgz1cUCC6OwdeM5Tr2P0hDuthr2iut4ggqsz+Cnh20U/LoTzbKIdDS/Nw==", "dev": true, - "license": "MIT" + "engines": { + "node": ">=20.18.1" + } }, "node_modules/unique-filename": { "version": "1.1.1", diff --git a/library/package.json b/library/package.json index 670108119..8dff0be31 100644 --- a/library/package.json +++ b/library/package.json @@ -96,9 +96,12 @@ "tap": "^18.6.1", "type-fest": "^4.24.0", "typescript": "^5.3.3", - "undici": "^6.12.0", "xml-js": "^1.6.11", - "xml2js": "^0.6.2" + "xml2js": "^0.6.2", + "undici-v4": "npm:undici@^4.0.0", + "undici-v5": "npm:undici@^5.0.0", + "undici-v6": "npm:undici@^6.0.0", + "undici-v7": "npm:undici@^7.0.0" }, "scripts": { "test": "node ../scripts/run-tap.js", diff --git a/library/sinks/Undici.custom.test.ts b/library/sinks/Undici.custom.test.ts index 94af05f23..eb7687523 100644 --- a/library/sinks/Undici.custom.test.ts +++ b/library/sinks/Undici.custom.test.ts @@ -3,10 +3,10 @@ import * as dns from "dns"; import * as t from "tap"; import { Token } from "../agent/api/Token"; import { Context, runWithContext } from "../agent/Context"; +import { startTestAgent } from "../helpers/startTestAgent"; import { wrap } from "../helpers/wrap"; import { getMajorNodeVersion } from "../helpers/getNodeVersion"; import { Undici } from "./Undici"; -import { createTestAgent } from "../helpers/createTestAgent"; function createContext(): Context { return { @@ -42,13 +42,14 @@ t.test( getMajorNodeVersion() <= 16 ? "ReadableStream is not available" : false, }, async (t) => { - const agent = createTestAgent({ + startTestAgent({ token: new Token("123"), + wrappers: [new Undici()], + rewrite: { undici: "undici-v6" }, }); - agent.start([new Undici()]); const { request, Dispatcher, setGlobalDispatcher, getGlobalDispatcher } = - require("undici") as typeof import("undici"); + require("undici-v6") as typeof import("undici-v6"); // See https://www.npmjs.com/package/@n8n_io/license-sdk // They set a custom dispatcher to proxy certain requests diff --git a/library/sinks/Undici.localhost.test.ts b/library/sinks/Undici.localhost.test.ts index 705741da3..eaea58fc8 100644 --- a/library/sinks/Undici.localhost.test.ts +++ b/library/sinks/Undici.localhost.test.ts @@ -1,13 +1,10 @@ /* eslint-disable prefer-rest-params */ import * as t from "tap"; -import { Agent } from "../agent/Agent"; import { createServer, Server } from "http"; -import { ReportingAPIForTesting } from "../agent/api/ReportingAPIForTesting"; import { Token } from "../agent/api/Token"; import { Context, runWithContext } from "../agent/Context"; -import { LoggerNoop } from "../agent/logger/LoggerNoop"; -import { createTestAgent } from "../helpers/createTestAgent"; import { getMajorNodeVersion } from "../helpers/getNodeVersion"; +import { startTestAgent } from "../helpers/startTestAgent"; import { Undici } from "./Undici"; function createContext({ @@ -53,12 +50,12 @@ function createContext({ }; } -const agent = createTestAgent({ +startTestAgent({ token: new Token("123"), + wrappers: [new Undici()], + rewrite: { undici: "undici-v6" }, }); -agent.start([new Undici()]); - const port = 1346; const serverUrl = `http://localhost:${port}`; const hostHeader = `localhost:${port}`; @@ -83,7 +80,7 @@ t.test( getMajorNodeVersion() <= 16 ? "ReadableStream is not available" : false, }, async (t) => { - const { request } = require("undici"); + const { request } = require("undici-v6") as typeof import("undici-v6"); await runWithContext( createContext({ @@ -108,7 +105,7 @@ t.test( getMajorNodeVersion() <= 16 ? "ReadableStream is not available" : false, }, async (t) => { - const { request } = require("undici"); + const { request } = require("undici-v6") as typeof import("undici-v6"); const error = await t.rejects(async () => { await runWithContext( diff --git a/library/sinks/Undici.test.ts b/library/sinks/Undici.test.ts deleted file mode 100644 index a74403371..000000000 --- a/library/sinks/Undici.test.ts +++ /dev/null @@ -1,355 +0,0 @@ -/* eslint-disable prefer-rest-params */ -import * as dns from "dns"; -import * as t from "tap"; -import { ReportingAPIForTesting } from "../agent/api/ReportingAPIForTesting"; -import { Token } from "../agent/api/Token"; -import { Context, runWithContext } from "../agent/Context"; -import { LoggerForTesting } from "../agent/logger/LoggerForTesting"; -import { wrap } from "../helpers/wrap"; -import { getMajorNodeVersion } from "../helpers/getNodeVersion"; -import { Undici } from "./Undici"; -import { createTestAgent } from "../helpers/createTestAgent"; - -const calls: Record = {}; -wrap(dns, "lookup", function lookup(original) { - return function lookup() { - const hostname = arguments[0]; - - if (!calls[hostname]) { - calls[hostname] = 0; - } - - calls[hostname]++; - - if (hostname === "thisdomainpointstointernalip.com") { - return original.apply( - // @ts-expect-error We don't know the type of `this` - this, - ["localhost", ...Array.from(arguments).slice(1)] - ); - } - - if (hostname === "example,prefix.thisdomainpointstointernalip.com") { - return original.apply( - // @ts-expect-error We don't know the type of `this` - this, - ["localhost", ...Array.from(arguments).slice(1)] - ); - } - - original.apply( - // @ts-expect-error We don't know the type of `this` - this, - arguments - ); - }; -}); - -function createContext(): Context { - return { - remoteAddress: "::1", - method: "POST", - url: "http://localhost:4000", - query: {}, - headers: {}, - body: { - image: "http://localhost:4000/api/internal", - }, - cookies: {}, - routeParams: {}, - source: "express", - route: "/posts/:id", - }; -} - -let server: ReturnType; -t.before(() => { - const http = require("http") as typeof import("http"); - server = http.createServer((req, res) => { - res.end("Hello, world!"); - }); - server.unref(); - server.listen(4000); -}); - -t.test( - "it works", - { - skip: - getMajorNodeVersion() <= 16 ? "ReadableStream is not available" : false, - }, - async (t) => { - const logger = new LoggerForTesting(); - const api = new ReportingAPIForTesting({ - success: true, - endpoints: [], - configUpdatedAt: 0, - heartbeatIntervalInMS: 10 * 60 * 1000, - blockedUserIds: [], - allowedIPAddresses: ["1.2.3.4"], - block: true, - receivedAnyStats: false, - }); - const agent = createTestAgent({ - api, - logger, - token: new Token("123"), - }); - agent.start([new Undici()]); - - const { - request, - fetch, - setGlobalDispatcher, - Agent: UndiciAgent, - } = require("undici"); - - await request("https://app.aikido.dev"); - t.same(agent.getHostnames().asArray(), [ - { hostname: "app.aikido.dev", port: 443 }, - ]); - agent.getHostnames().clear(); - - await fetch("https://app.aikido.dev"); - t.same(agent.getHostnames().asArray(), [ - { hostname: "app.aikido.dev", port: 443 }, - ]); - agent.getHostnames().clear(); - - await request({ - protocol: "https:", - hostname: "app.aikido.dev", - port: 443, - }); - t.same(agent.getHostnames().asArray(), [ - { hostname: "app.aikido.dev", port: 443 }, - ]); - agent.getHostnames().clear(); - - await request({ - protocol: "https:", - hostname: "app.aikido.dev", - port: "443", - }); - t.same(agent.getHostnames().asArray(), [ - { hostname: "app.aikido.dev", port: "443" }, - ]); - agent.getHostnames().clear(); - - await request({ - protocol: "https:", - hostname: "app.aikido.dev", - port: undefined, - }); - t.same(agent.getHostnames().asArray(), [ - { hostname: "app.aikido.dev", port: 443 }, - ]); - agent.getHostnames().clear(); - - await request({ - protocol: "http:", - hostname: "app.aikido.dev", - port: undefined, - }); - t.same(agent.getHostnames().asArray(), [ - { hostname: "app.aikido.dev", port: 80 }, - ]); - agent.getHostnames().clear(); - - await request({ - protocol: "https:", - hostname: "app.aikido.dev", - port: "443", - }); - t.same(agent.getHostnames().asArray(), [ - { hostname: "app.aikido.dev", port: "443" }, - ]); - agent.getHostnames().clear(); - - await request(new URL("https://app.aikido.dev")); - t.same(agent.getHostnames().asArray(), [ - { hostname: "app.aikido.dev", port: 443 }, - ]); - agent.getHostnames().clear(); - - await request(require("url").parse("https://app.aikido.dev")); - t.same(agent.getHostnames().asArray(), [ - { hostname: "app.aikido.dev", port: "443" }, - ]); - agent.getHostnames().clear(); - - await request({ - origin: "https://app.aikido.dev", - }); - t.same(agent.getHostnames().asArray(), [ - { hostname: "app.aikido.dev", port: "443" }, - ]); - agent.getHostnames().clear(); - - await request(require("url").parse("https://app.aikido.dev")); - t.same(agent.getHostnames().asArray(), [ - { hostname: "app.aikido.dev", port: "443" }, - ]); - agent.getHostnames().clear(); - - await request({ - origin: "https://app.aikido.dev", - }); - t.same(agent.getHostnames().asArray(), [ - { hostname: "app.aikido.dev", port: "443" }, - ]); - agent.getHostnames().clear(); - - await t.rejects(() => request("invalid url")); - await t.rejects(() => request({ hostname: "" })); - - await runWithContext( - { - ...createContext(), - remoteAddress: "1.2.3.4", - }, - async () => { - // Bypass the block using an allowed IP - await request("http://localhost:4000/api/internal"); - } - ); - - await runWithContext(createContext(), async () => { - await request("https://google.com"); - - const error0 = await t.rejects(() => request("http://localhost:9876")); - if (error0 instanceof Error) { - // @ts-expect-error Added in Node.js 16.9.0, but because this test is skipped in Node.js 16 because of the lack of fetch, it's fine - t.same(error0.code, "ECONNREFUSED"); - } - - const error1 = await t.rejects(() => - request("http://localhost:4000/api/internal") - ); - if (error1 instanceof Error) { - t.same( - error1.message, - "Zen has blocked a server-side request forgery: undici.request(...) originating from body.image" - ); - } - - const events = api - .getEvents() - .filter((e) => e.type === "detected_attack"); - t.same(events.length, 1); - t.same(events[0].attack.metadata, { - hostname: "localhost", - port: 4000, - }); - - const error2 = await t.rejects(() => - request(new URL("http://localhost:4000/api/internal")) - ); - if (error2 instanceof Error) { - t.same( - error2.message, - "Zen has blocked a server-side request forgery: undici.request(...) originating from body.image" - ); - } - const error3 = await t.rejects(() => - request({ - protocol: "http:", - hostname: "localhost", - port: 4000, - path: "/api/internal", - }) - ); - if (error3 instanceof Error) { - t.same( - error3.message, - "Zen has blocked a server-side request forgery: undici.request(...) originating from body.image" - ); - } - - const error4 = await t.rejects(() => - fetch(["http://localhost:4000/api/internal"]) - ); - if (error4 instanceof Error) { - t.same( - error4.message, - "Zen has blocked a server-side request forgery: undici.fetch(...) originating from body.image" - ); - } - - const oldUrl = require("url"); - const error5 = t.throws(() => - request(oldUrl.parse("https://localhost:4000/api/internal")) - ); - if (error5 instanceof Error) { - t.same( - error5.message, - "Zen has blocked a server-side request forgery: undici.request(...) originating from body.image" - ); - } - }); - - await runWithContext( - { ...createContext(), routeParams: { param: "http://0" } }, - async () => { - const error = await t.rejects(() => request("http://0")); - if (error instanceof Error) { - t.same( - error.message, - "Zen has blocked a server-side request forgery: undici.request(...) originating from routeParams.param" - ); - } - } - ); - - await runWithContext( - { - ...createContext(), - ...{ - body: { - image2: [ - "http://example", - "prefix.thisdomainpointstointernalip.com", - ], - image: "http://thisdomainpointstointernalip.com/path", - }, - }, - }, - async () => { - const error = await t.rejects(() => - request("http://thisdomainpointstointernalip.com") - ); - if (error instanceof Error) { - t.same( - error.message, - "Zen has blocked a server-side request forgery: undici.[method](...) originating from body.image" - ); - } - - const error2 = await t.rejects(() => - fetch(["http://example", "prefix.thisdomainpointstointernalip.com"]) - ); - if (error2 instanceof Error) { - t.same( - // @ts-expect-error Type is not defined - error2.cause.message, - "Zen has blocked a server-side request forgery: undici.[method](...) originating from body.image2" - ); - } - - // Ensure the lookup is only called once per hostname - // Otherwise, it could be vulnerable to TOCTOU - t.same(calls["thisdomainpointstointernalip.com"], 1); - } - ); - - logger.clear(); - setGlobalDispatcher(new UndiciAgent({})); - t.same(logger.getMessages(), [ - "undici.setGlobalDispatcher(..) was called, we can't guarantee protection!", - ]); - } -); - -t.after(() => { - server.close(); -}); diff --git a/library/sinks/Undici.tests.ts b/library/sinks/Undici.tests.ts new file mode 100644 index 000000000..604ae7de5 --- /dev/null +++ b/library/sinks/Undici.tests.ts @@ -0,0 +1,363 @@ +/* eslint-disable prefer-rest-params, max-lines-per-function */ +import * as dns from "dns"; +import * as t from "tap"; +import { ReportingAPIForTesting } from "../agent/api/ReportingAPIForTesting"; +import { Token } from "../agent/api/Token"; +import { Context, runWithContext } from "../agent/Context"; +import { LoggerForTesting } from "../agent/logger/LoggerForTesting"; +import { startTestAgent } from "../helpers/startTestAgent"; +import { wrap } from "../helpers/wrap"; +import { getMajorNodeVersion } from "../helpers/getNodeVersion"; +import { Undici } from "./Undici"; + +export function createUndiciTests(undiciPkgName: string, port: number) { + const calls: Record = {}; + wrap(dns, "lookup", function lookup(original) { + return function lookup() { + const hostname = arguments[0]; + + if (!calls[hostname]) { + calls[hostname] = 0; + } + + calls[hostname]++; + + if (hostname === "thisdomainpointstointernalip.com") { + return original.apply( + // @ts-expect-error We don't know the type of `this` + this, + ["localhost", ...Array.from(arguments).slice(1)] + ); + } + + if (hostname === "example,prefix.thisdomainpointstointernalip.com") { + return original.apply( + // @ts-expect-error We don't know the type of `this` + this, + ["localhost", ...Array.from(arguments).slice(1)] + ); + } + + original.apply( + // @ts-expect-error We don't know the type of `this` + this, + arguments + ); + }; + }); + + function createContext(): Context { + return { + remoteAddress: "::1", + method: "POST", + url: `http://localhost:${port}}`, + query: {}, + headers: {}, + body: { + image: `http://localhost:${port}/api/internal`, + }, + cookies: {}, + routeParams: {}, + source: "express", + route: "/posts/:id", + }; + } + + let server: ReturnType; + t.before(() => { + const http = require("http") as typeof import("http"); + server = http.createServer((req, res) => { + res.end("Hello, world!"); + }); + server.unref(); + server.listen(port); + }); + + t.test( + "it works", + { + skip: + getMajorNodeVersion() <= 16 ? "ReadableStream is not available" : false, + }, + async (t) => { + const logger = new LoggerForTesting(); + const api = new ReportingAPIForTesting({ + success: true, + endpoints: [], + configUpdatedAt: 0, + heartbeatIntervalInMS: 10 * 60 * 1000, + blockedUserIds: [], + allowedIPAddresses: ["1.2.3.4"], + block: true, + receivedAnyStats: false, + }); + const agent = startTestAgent({ + api, + logger, + token: new Token("123"), + wrappers: [new Undici()], + rewrite: { + undici: undiciPkgName, + }, + }); + + const { + request, + fetch, + setGlobalDispatcher, + Agent: UndiciAgent, + } = require(undiciPkgName) as typeof import("undici-v6"); + + await request("https://app.aikido.dev"); + t.same(agent.getHostnames().asArray(), [ + { hostname: "app.aikido.dev", port: 443 }, + ]); + agent.getHostnames().clear(); + + await fetch("https://app.aikido.dev"); + t.same(agent.getHostnames().asArray(), [ + { hostname: "app.aikido.dev", port: 443 }, + ]); + agent.getHostnames().clear(); + + await request({ + protocol: "https:", + hostname: "app.aikido.dev", + port: 443, + }); + t.same(agent.getHostnames().asArray(), [ + { hostname: "app.aikido.dev", port: 443 }, + ]); + agent.getHostnames().clear(); + + await request({ + protocol: "https:", + hostname: "app.aikido.dev", + port: "443", + }); + t.same(agent.getHostnames().asArray(), [ + { hostname: "app.aikido.dev", port: "443" }, + ]); + agent.getHostnames().clear(); + + await request({ + protocol: "https:", + hostname: "app.aikido.dev", + port: undefined, + }); + t.same(agent.getHostnames().asArray(), [ + { hostname: "app.aikido.dev", port: 443 }, + ]); + agent.getHostnames().clear(); + + await request({ + protocol: "http:", + hostname: "app.aikido.dev", + port: undefined, + }); + t.same(agent.getHostnames().asArray(), [ + { hostname: "app.aikido.dev", port: 80 }, + ]); + agent.getHostnames().clear(); + + await request({ + protocol: "https:", + hostname: "app.aikido.dev", + port: "443", + }); + t.same(agent.getHostnames().asArray(), [ + { hostname: "app.aikido.dev", port: "443" }, + ]); + agent.getHostnames().clear(); + + await request(new URL("https://app.aikido.dev")); + t.same(agent.getHostnames().asArray(), [ + { hostname: "app.aikido.dev", port: 443 }, + ]); + agent.getHostnames().clear(); + + await request(require("url").parse("https://app.aikido.dev")); + t.same(agent.getHostnames().asArray(), [ + { hostname: "app.aikido.dev", port: "443" }, + ]); + agent.getHostnames().clear(); + + await request({ + origin: "https://app.aikido.dev", + } as URL); + t.same(agent.getHostnames().asArray(), [ + { hostname: "app.aikido.dev", port: "443" }, + ]); + agent.getHostnames().clear(); + + await request(require("url").parse("https://app.aikido.dev")); + t.same(agent.getHostnames().asArray(), [ + { hostname: "app.aikido.dev", port: "443" }, + ]); + agent.getHostnames().clear(); + + await request({ + origin: "https://app.aikido.dev", + } as URL); + t.same(agent.getHostnames().asArray(), [ + { hostname: "app.aikido.dev", port: "443" }, + ]); + agent.getHostnames().clear(); + + await t.rejects(() => request("invalid url")); + await t.rejects(() => request({ hostname: "" })); + + await runWithContext( + { + ...createContext(), + remoteAddress: "1.2.3.4", + }, + async () => { + // Bypass the block using an allowed IP + await request(`http://localhost:${port}/api/internal`); + } + ); + + await runWithContext(createContext(), async () => { + await request("https://google.com"); + + const error0 = await t.rejects(() => request("http://localhost:9876")); + if (error0 instanceof Error) { + // @ts-expect-error Added in Node.js 16.9.0, but because this test is skipped in Node.js 16 because of the lack of fetch, it's fine + t.same(error0.code, "ECONNREFUSED"); + } + + const error1 = await t.rejects(() => + request(`http://localhost:${port}/api/internal`) + ); + if (error1 instanceof Error) { + t.same( + error1.message, + "Zen has blocked a server-side request forgery: undici.request(...) originating from body.image" + ); + } + + const events = api + .getEvents() + .filter((e) => e.type === "detected_attack"); + t.same(events.length, 1); + t.same(events[0].attack.metadata, { + hostname: "localhost", + port: port, + }); + + const error2 = await t.rejects(() => + request(new URL(`http://localhost:${port}/api/internal`)) + ); + if (error2 instanceof Error) { + t.same( + error2.message, + "Zen has blocked a server-side request forgery: undici.request(...) originating from body.image" + ); + } + const error3 = await t.rejects(() => + request({ + protocol: "http:", + hostname: "localhost", + port: port, + pathname: "/api/internal", + }) + ); + if (error3 instanceof Error) { + t.same( + error3.message, + "Zen has blocked a server-side request forgery: undici.request(...) originating from body.image" + ); + } + + const error4 = await t.rejects(() => + fetch([`http://localhost:${port}/api/internal`] as unknown as string) + ); + if (error4 instanceof Error) { + t.same( + error4.message, + "Zen has blocked a server-side request forgery: undici.fetch(...) originating from body.image" + ); + } + + const oldUrl = require("url"); + const error5 = t.throws(() => + request(oldUrl.parse(`https://localhost:${port}/api/internal`)) + ); + if (error5 instanceof Error) { + t.same( + error5.message, + "Zen has blocked a server-side request forgery: undici.request(...) originating from body.image" + ); + } + }); + + await runWithContext( + { ...createContext(), routeParams: { param: "http://0" } }, + async () => { + const error = await t.rejects(() => request("http://0")); + if (error instanceof Error) { + t.same( + error.message, + "Zen has blocked a server-side request forgery: undici.request(...) originating from routeParams.param" + ); + } + } + ); + + await runWithContext( + { + ...createContext(), + ...{ + body: { + image2: [ + "http://example", + "prefix.thisdomainpointstointernalip.com", + ], + image: "http://thisdomainpointstointernalip.com/path", + }, + }, + }, + async () => { + const error = await t.rejects(() => + request("http://thisdomainpointstointernalip.com") + ); + if (error instanceof Error) { + t.same( + error.message, + "Zen has blocked a server-side request forgery: undici.[method](...) originating from body.image" + ); + } + + const error2 = await t.rejects(() => + fetch([ + "http://example", + "prefix.thisdomainpointstointernalip.com", + ] as unknown as string) + ); + if (error2 instanceof Error) { + t.same( + // @ts-expect-error Type is not defined + error2.cause.message, + "Zen has blocked a server-side request forgery: undici.[method](...) originating from body.image2" + ); + } + + // Ensure the lookup is only called once per hostname + // Otherwise, it could be vulnerable to TOCTOU + t.same(calls["thisdomainpointstointernalip.com"], 1); + } + ); + + logger.clear(); + setGlobalDispatcher(new UndiciAgent({})); + t.same(logger.getMessages(), [ + "undici.setGlobalDispatcher(..) was called, we can't guarantee protection!", + ]); + } + ); + + t.after(() => { + server.close(); + }); +} diff --git a/library/sinks/Undici.ts b/library/sinks/Undici.ts index 777937428..041dcca52 100644 --- a/library/sinks/Undici.ts +++ b/library/sinks/Undici.ts @@ -5,10 +5,8 @@ import { getContext } from "../agent/Context"; import { Hooks } from "../agent/hooks/Hooks"; import { InterceptorResult } from "../agent/hooks/InterceptorResult"; import { Wrapper } from "../agent/Wrapper"; -import { - getMajorNodeVersion, - getMinorNodeVersion, -} from "../helpers/getNodeVersion"; +import { getSemverNodeVersion } from "../helpers/getNodeVersion"; +import { isVersionGreaterOrEqual } from "../helpers/isVersionGreaterOrEqual"; import { checkContextForSSRF } from "../vulnerabilities/ssrf/checkContextForSSRF"; import { inspectDNSLookupCalls } from "../vulnerabilities/ssrf/inspectDNSLookupCalls"; import { wrapDispatch } from "./undici/wrapDispatch"; @@ -72,7 +70,7 @@ export class Undici implements Wrapper { private patchGlobalDispatcher( agent: Agent, - undiciModule: typeof import("undici") + undiciModule: typeof import("undici-v6") ) { const dispatcher = new undiciModule.Agent({ connect: { @@ -93,20 +91,14 @@ export class Undici implements Wrapper { } wrap(hooks: Hooks) { - const supported = - getMajorNodeVersion() >= 17 || - (getMajorNodeVersion() === 16 && getMinorNodeVersion() >= 8); - - if (!supported) { - // Undici requires Node.js 16.8+ - // Packages aren't scoped in npm workspaces, we'll try to require undici: - // ReferenceError: ReadableStream is not defined + if (!isVersionGreaterOrEqual("16.8.0", getSemverNodeVersion())) { + // Undici requires Node.js 16.8+ (due to web streams) return; } hooks .addPackage("undici") - .withVersion("^4.0.0 || ^5.0.0 || ^6.0.0") + .withVersion("^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0") .onRequire((exports, pkgInfo) => { const agent = getInstance(); diff --git a/library/sinks/Undici.v4.test.ts b/library/sinks/Undici.v4.test.ts new file mode 100644 index 000000000..6e7c3f4fd --- /dev/null +++ b/library/sinks/Undici.v4.test.ts @@ -0,0 +1,3 @@ +import { createUndiciTests } from "./Undici.tests"; + +createUndiciTests("undici-v4", 4004); diff --git a/library/sinks/Undici.v5.test.ts b/library/sinks/Undici.v5.test.ts new file mode 100644 index 000000000..c6ff6ec53 --- /dev/null +++ b/library/sinks/Undici.v5.test.ts @@ -0,0 +1,3 @@ +import { createUndiciTests } from "./Undici.tests"; + +createUndiciTests("undici-v5", 4005); diff --git a/library/sinks/Undici.v6.test.ts b/library/sinks/Undici.v6.test.ts new file mode 100644 index 000000000..eea708db8 --- /dev/null +++ b/library/sinks/Undici.v6.test.ts @@ -0,0 +1,3 @@ +import { createUndiciTests } from "./Undici.tests"; + +createUndiciTests("undici-v6", 4006); diff --git a/library/sinks/Undici.v7.test.ts b/library/sinks/Undici.v7.test.ts new file mode 100644 index 000000000..1f975204f --- /dev/null +++ b/library/sinks/Undici.v7.test.ts @@ -0,0 +1,3 @@ +import { createUndiciTests } from "./Undici.tests"; + +createUndiciTests("undici-v7", 4007); diff --git a/library/sinks/undici/wrapDispatch.ts b/library/sinks/undici/wrapDispatch.ts index 4cf40f0d7..94ac34d5b 100644 --- a/library/sinks/undici/wrapDispatch.ts +++ b/library/sinks/undici/wrapDispatch.ts @@ -1,4 +1,4 @@ -import type { Dispatcher } from "undici"; +import type { Dispatcher } from "undici-v6"; import { getMetadataForSSRFAttack } from "../../vulnerabilities/ssrf/getMetadataForSSRFAttack"; import { RequestContextStorage } from "./RequestContextStorage"; import { Context, getContext } from "../../agent/Context"; diff --git a/library/sinks/undici/wrapOnHeaders.ts b/library/sinks/undici/wrapOnHeaders.ts index 203635778..153c9bde6 100644 --- a/library/sinks/undici/wrapOnHeaders.ts +++ b/library/sinks/undici/wrapOnHeaders.ts @@ -1,7 +1,7 @@ import { RequestContextStorage } from "./RequestContextStorage"; import { parseHeaders } from "./parseHeaders"; import { isRedirectStatusCode } from "../../helpers/isRedirectStatusCode"; -import type { Dispatcher } from "undici"; +import type { Dispatcher } from "undici-v6"; import { Context } from "../../agent/Context"; import { onRedirect } from "./onRedirect";