From 2f41e4bbbb8b62257637e8e001e56a0afc7763fb Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 1 Dec 2025 17:36:55 +0100 Subject: [PATCH 01/14] Add hook to monitor outbound requests --- docs/outbound-requests.md | 21 ++++++++++++ library/agent/Agent.ts | 9 ++++-- library/agent/hooks/outboundRequest.ts | 41 ++++++++++++++++++++++++ library/index.ts | 3 ++ library/sinks/Fetch.ts | 44 ++++++++++++++------------ library/sinks/HTTPRequest.ts | 18 +++++++++-- library/sinks/Undici.ts | 6 ++-- 7 files changed, 113 insertions(+), 29 deletions(-) create mode 100644 docs/outbound-requests.md create mode 100644 library/agent/hooks/outboundRequest.ts diff --git a/docs/outbound-requests.md b/docs/outbound-requests.md new file mode 100644 index 000000000..9c272897e --- /dev/null +++ b/docs/outbound-requests.md @@ -0,0 +1,21 @@ +# Monitoring Outbound Requests + +To monitor outbound HTTP/HTTPS requests made by your application, you can use the `onOutboundRequest` function. This is useful when you want to track external API calls, log outbound traffic, or analyze what domains your application connects to. + +## Basic Usage + +```js +const { onOutboundRequest } = require("@aikidosec/firewall"); + +onOutboundRequest(({ url, port, method }) => { + // url is a URL object: https://nodejs.org/api/url.html#class-url + console.log(`${new Date().toISOString()} - ${method} ${url.href}`); +}); +``` + +## Important Notes + +- You can register multiple callbacks by calling `onOutboundRequest` multiple times. +- Callbacks are triggered for all HTTP/HTTPS requests made through Node.js built-in modules (`http`, `https`), builtin fetch function, undici and anything that uses that. +- Callbacks are called when the connection is initiated, before knowing if Zen will block the request. +- Errors thrown in callbacks (both sync and async) are silently caught and not logged to prevent breaking your application. diff --git a/library/agent/Agent.ts b/library/agent/Agent.ts index a5165a2a6..805c4bc00 100644 --- a/library/agent/Agent.ts +++ b/library/agent/Agent.ts @@ -27,6 +27,7 @@ import { wrapInstalledPackages } from "./wrapInstalledPackages"; import { Wrapper } from "./Wrapper"; import { isAikidoCI } from "../helpers/isAikidoCI"; import { AttackLogger } from "./AttackLogger"; +import { triggerOutboundRequestHooks } from "./hooks/outboundRequest"; import { Packages } from "./Packages"; import { AIStatistics } from "./AIStatistics"; import { isNewInstrumentationUnitTest } from "../helpers/isNewInstrumentationUnitTest"; @@ -564,8 +565,12 @@ export class Agent { } } - onConnectHostname(hostname: string, port: number) { - this.hostnames.add(hostname, port); + onConnectHostname(url: URL, port: number) { + this.hostnames.add(url.hostname, port); + } + + onConnectHTTP(url: URL, port: number, method: string) { + triggerOutboundRequestHooks({ url, port, method }); } onRouteExecute(context: Context) { diff --git a/library/agent/hooks/outboundRequest.ts b/library/agent/hooks/outboundRequest.ts new file mode 100644 index 000000000..7df06dff3 --- /dev/null +++ b/library/agent/hooks/outboundRequest.ts @@ -0,0 +1,41 @@ +export type OutboundRequestInfo = { + url: URL; + port: number; + method: string; +}; + +type OutboundRequestCallback = ( + request: OutboundRequestInfo +) => void | Promise; + +const outboundRequestCallbacks = new Set(); + +export function onOutboundRequest(callback: OutboundRequestCallback): void { + if (typeof callback !== "function") { + throw new TypeError("Callback must be a function"); + } + + outboundRequestCallbacks.add(callback); +} + +export function triggerOutboundRequestHooks( + request: OutboundRequestInfo +): void { + if (outboundRequestCallbacks.size === 0) { + return; + } + + outboundRequestCallbacks.forEach((callback) => { + try { + const result = callback(request); + // If it returns a promise, catch any errors but don't wait + if (result instanceof Promise) { + result.catch(() => { + // Silently ignore errors from user hooks + }); + } + } catch { + // Silently ignore errors from user hooks + } + }); +} diff --git a/library/index.ts b/library/index.ts index ab2b6925c..855219f18 100644 --- a/library/index.ts +++ b/library/index.ts @@ -15,6 +15,7 @@ import { isESM } from "./helpers/isESM"; import { checkIndexImportGuard } from "./helpers/indexImportGuard"; import { setRateLimitGroup } from "./ratelimiting/group"; import { isLibBundled } from "./helpers/isLibBundled"; +import { onOutboundRequest } from "./agent/hooks/outboundRequest"; // Prevent logging twice / trying to start agent twice if (!isNewHookSystemUsed()) { @@ -51,6 +52,7 @@ export { addKoaMiddleware, addRestifyMiddleware, setRateLimitGroup, + onOutboundRequest, }; // Required for ESM / TypeScript default export support @@ -67,4 +69,5 @@ export default { addKoaMiddleware, addRestifyMiddleware, setRateLimitGroup, + onOutboundRequest, }; diff --git a/library/sinks/Fetch.ts b/library/sinks/Fetch.ts index 9931f8d28..ec82c4f2d 100644 --- a/library/sinks/Fetch.ts +++ b/library/sinks/Fetch.ts @@ -16,13 +16,15 @@ export class Fetch implements Wrapper { private inspectHostname( agent: Agent, - hostname: string, - port: number | undefined + url: URL, + port: number | undefined, + method: string ): InterceptorResult { // Let the agent know that we are connecting to this hostname // This is to build a list of all hostnames that the application is connecting to if (typeof port === "number" && port > 0) { - agent.onConnectHostname(hostname, port); + agent.onConnectHostname(url, port); + agent.onConnectHTTP(url, port, method); } const context = getContext(); @@ -31,7 +33,7 @@ export class Fetch implements Wrapper { } return checkContextForSSRF({ - hostname: hostname, + hostname: url.hostname, operation: "fetch", context: context, port: port, @@ -40,15 +42,22 @@ export class Fetch implements Wrapper { inspectFetch(args: unknown[], agent: Agent): InterceptorResult { if (args.length > 0) { + // Extract method from options or Request object + let method = "GET"; + if (args[0] instanceof Request) { + method = args[0].method.toUpperCase(); + } else if (args.length > 1 && args[1] && typeof args[1] === "object") { + const options = args[1] as { method?: string }; + if (options.method) { + method = options.method.toUpperCase(); + } + } + // URL string if (typeof args[0] === "string" && args[0].length > 0) { const url = tryParseURL(args[0]); if (url) { - const attack = this.inspectHostname( - agent, - url.hostname, - getPortFromURL(url) - ); + const attack = this.inspectHostname(agent, url, getPortFromURL(url), method); if (attack) { return attack; } @@ -62,11 +71,7 @@ export class Fetch implements Wrapper { if (Array.isArray(args[0])) { const url = tryParseURL(args[0].toString()); if (url) { - const attack = this.inspectHostname( - agent, - url.hostname, - getPortFromURL(url) - ); + const attack = this.inspectHostname(agent, url, getPortFromURL(url), method); if (attack) { return attack; } @@ -77,8 +82,9 @@ export class Fetch implements Wrapper { if (args[0] instanceof URL && args[0].hostname.length > 0) { const attack = this.inspectHostname( agent, - args[0].hostname, - getPortFromURL(args[0]) + args[0], + getPortFromURL(args[0]), + method ); if (attack) { return attack; @@ -89,11 +95,7 @@ export class Fetch implements Wrapper { if (args[0] instanceof Request) { const url = tryParseURL(args[0].url); if (url) { - const attack = this.inspectHostname( - agent, - url.hostname, - getPortFromURL(url) - ); + const attack = this.inspectHostname(agent, url, getPortFromURL(url), method); if (attack) { return attack; } diff --git a/library/sinks/HTTPRequest.ts b/library/sinks/HTTPRequest.ts index 6ac39fe9e..430941294 100644 --- a/library/sinks/HTTPRequest.ts +++ b/library/sinks/HTTPRequest.ts @@ -19,12 +19,14 @@ export class HTTPRequest implements Wrapper { agent: Agent, url: URL, port: number | undefined, - module: "http" | "https" + module: "http" | "https", + method: string ): InterceptorResult { // Let the agent know that we are connecting to this hostname // This is to build a list of all hostnames that the application is connecting to if (typeof port === "number" && port > 0) { - agent.onConnectHostname(url.hostname, port); + agent.onConnectHostname(url, port); + agent.onConnectHTTP(url, port, method); } const context = getContext(); @@ -74,11 +76,21 @@ export class HTTPRequest implements Wrapper { } if (url.hostname.length > 0) { + // Extract method from options object + let method = "GET"; + const optionObj = args.find((arg): arg is RequestOptions => + isOptionsObject(arg) + ); + if (optionObj && optionObj.method) { + method = optionObj.method.toUpperCase(); + } + const attack = this.inspectHostname( agent, url, getPortFromURL(url), - module + module, + method ); if (attack) { return attack; diff --git a/library/sinks/Undici.ts b/library/sinks/Undici.ts index 1c9fef3b6..931ba3223 100644 --- a/library/sinks/Undici.ts +++ b/library/sinks/Undici.ts @@ -26,14 +26,14 @@ const methods = [ export class Undici implements Wrapper { private inspectHostname( agent: Agent, - hostname: string, + url: URL, port: number | undefined, method: string ): InterceptorResult { // Let the agent know that we are connecting to this hostname // This is to build a list of all hostnames that the application is connecting to if (typeof port === "number" && port > 0) { - agent.onConnectHostname(hostname, port); + agent.onConnectHostname(url, port); } const context = getContext(); @@ -42,7 +42,7 @@ export class Undici implements Wrapper { } return checkContextForSSRF({ - hostname: hostname, + hostname: url.hostname, operation: `undici.${method}`, context, port, From 7bfd74ff4cf5d623b3077728cec2ba416c1b341c Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 1 Dec 2025 19:09:55 +0100 Subject: [PATCH 02/14] Revert change to onConnectHostname --- library/agent/Agent.ts | 4 ++-- library/sinks/Fetch.ts | 23 +++++++++++++++++++---- library/sinks/HTTPRequest.ts | 2 +- library/sinks/Undici.ts | 6 +++--- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/library/agent/Agent.ts b/library/agent/Agent.ts index 805c4bc00..5ae988244 100644 --- a/library/agent/Agent.ts +++ b/library/agent/Agent.ts @@ -565,8 +565,8 @@ export class Agent { } } - onConnectHostname(url: URL, port: number) { - this.hostnames.add(url.hostname, port); + onConnectHostname(hostname: string, port: number) { + this.hostnames.add(hostname, port); } onConnectHTTP(url: URL, port: number, method: string) { diff --git a/library/sinks/Fetch.ts b/library/sinks/Fetch.ts index ec82c4f2d..74bd7a4a9 100644 --- a/library/sinks/Fetch.ts +++ b/library/sinks/Fetch.ts @@ -23,7 +23,7 @@ export class Fetch implements Wrapper { // Let the agent know that we are connecting to this hostname // This is to build a list of all hostnames that the application is connecting to if (typeof port === "number" && port > 0) { - agent.onConnectHostname(url, port); + agent.onConnectHostname(url.hostname, port); agent.onConnectHTTP(url, port, method); } const context = getContext(); @@ -57,7 +57,12 @@ export class Fetch implements Wrapper { if (typeof args[0] === "string" && args[0].length > 0) { const url = tryParseURL(args[0]); if (url) { - const attack = this.inspectHostname(agent, url, getPortFromURL(url), method); + const attack = this.inspectHostname( + agent, + url, + getPortFromURL(url), + method + ); if (attack) { return attack; } @@ -71,7 +76,12 @@ export class Fetch implements Wrapper { if (Array.isArray(args[0])) { const url = tryParseURL(args[0].toString()); if (url) { - const attack = this.inspectHostname(agent, url, getPortFromURL(url), method); + const attack = this.inspectHostname( + agent, + url, + getPortFromURL(url), + method + ); if (attack) { return attack; } @@ -95,7 +105,12 @@ export class Fetch implements Wrapper { if (args[0] instanceof Request) { const url = tryParseURL(args[0].url); if (url) { - const attack = this.inspectHostname(agent, url, getPortFromURL(url), method); + const attack = this.inspectHostname( + agent, + url, + getPortFromURL(url), + method + ); if (attack) { return attack; } diff --git a/library/sinks/HTTPRequest.ts b/library/sinks/HTTPRequest.ts index 430941294..68a6db1d9 100644 --- a/library/sinks/HTTPRequest.ts +++ b/library/sinks/HTTPRequest.ts @@ -25,7 +25,7 @@ export class HTTPRequest implements Wrapper { // Let the agent know that we are connecting to this hostname // This is to build a list of all hostnames that the application is connecting to if (typeof port === "number" && port > 0) { - agent.onConnectHostname(url, port); + agent.onConnectHostname(url.hostname, port); agent.onConnectHTTP(url, port, method); } const context = getContext(); diff --git a/library/sinks/Undici.ts b/library/sinks/Undici.ts index 931ba3223..1c9fef3b6 100644 --- a/library/sinks/Undici.ts +++ b/library/sinks/Undici.ts @@ -26,14 +26,14 @@ const methods = [ export class Undici implements Wrapper { private inspectHostname( agent: Agent, - url: URL, + hostname: string, port: number | undefined, method: string ): InterceptorResult { // Let the agent know that we are connecting to this hostname // This is to build a list of all hostnames that the application is connecting to if (typeof port === "number" && port > 0) { - agent.onConnectHostname(url, port); + agent.onConnectHostname(hostname, port); } const context = getContext(); @@ -42,7 +42,7 @@ export class Undici implements Wrapper { } return checkContextForSSRF({ - hostname: url.hostname, + hostname: hostname, operation: `undici.${method}`, context, port, From 7479c59723f4304620164925ff7725fd97dee545 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Tue, 2 Dec 2025 12:35:16 +0100 Subject: [PATCH 03/14] Refactor: use generic addHook/removeHook functions --- docs/outbound-requests.md | 32 ++++-- library/agent/Agent.ts | 4 +- library/agent/hooks.test.ts | 145 +++++++++++++++++++++++++ library/agent/hooks.ts | 60 ++++++++++ library/agent/hooks/outboundRequest.ts | 41 ------- library/index.ts | 8 +- 6 files changed, 237 insertions(+), 53 deletions(-) create mode 100644 library/agent/hooks.test.ts create mode 100644 library/agent/hooks.ts delete mode 100644 library/agent/hooks/outboundRequest.ts diff --git a/docs/outbound-requests.md b/docs/outbound-requests.md index 9c272897e..c97d4dcfd 100644 --- a/docs/outbound-requests.md +++ b/docs/outbound-requests.md @@ -1,21 +1,39 @@ # Monitoring Outbound Requests -To monitor outbound HTTP/HTTPS requests made by your application, you can use the `onOutboundRequest` function. This is useful when you want to track external API calls, log outbound traffic, or analyze what domains your application connects to. +To monitor outbound HTTP/HTTPS requests made by your application, you can use the `addHook` function with the `beforeOutboundRequest` hook. This is useful when you want to track external API calls, log outbound traffic, or analyze what domains your application connects to. ## Basic Usage ```js -const { onOutboundRequest } = require("@aikidosec/firewall"); +const { addHook } = require("@aikidosec/firewall"); -onOutboundRequest(({ url, port, method }) => { +addHook("beforeOutboundRequest", ({ url, port, method }) => { // url is a URL object: https://nodejs.org/api/url.html#class-url console.log(`${new Date().toISOString()} - ${method} ${url.href}`); }); ``` +## Removing Hooks + +You can remove a previously registered hook using the `removeHook` function: + +```js +const { addHook, removeHook } = require("@aikidosec/firewall"); + +function myHook({ url, port, method }) { + console.log(`${method} ${url.href}`); +} + +addHook("beforeOutboundRequest", myHook); + +// Later, when you want to remove it: +removeHook("beforeOutboundRequest", myHook); +``` + ## Important Notes -- You can register multiple callbacks by calling `onOutboundRequest` multiple times. -- Callbacks are triggered for all HTTP/HTTPS requests made through Node.js built-in modules (`http`, `https`), builtin fetch function, undici and anything that uses that. -- Callbacks are called when the connection is initiated, before knowing if Zen will block the request. -- Errors thrown in callbacks (both sync and async) are silently caught and not logged to prevent breaking your application. +- You can register multiple hooks by calling `addHook` multiple times. +- The same hook function can only be registered once (duplicates are automatically prevented). +- Hooks are triggered for all HTTP/HTTPS requests made through Node.js built-in modules (`http`, `https`), builtin fetch function, undici and anything that uses that. +- Hooks are called when the connection is initiated, before knowing if Zen will block the request. +- Errors thrown in hooks (both sync and async) are silently caught and not logged to prevent breaking your application. diff --git a/library/agent/Agent.ts b/library/agent/Agent.ts index 5ae988244..6114144f3 100644 --- a/library/agent/Agent.ts +++ b/library/agent/Agent.ts @@ -27,7 +27,7 @@ import { wrapInstalledPackages } from "./wrapInstalledPackages"; import { Wrapper } from "./Wrapper"; import { isAikidoCI } from "../helpers/isAikidoCI"; import { AttackLogger } from "./AttackLogger"; -import { triggerOutboundRequestHooks } from "./hooks/outboundRequest"; +import { executeHooks } from "./hooks"; import { Packages } from "./Packages"; import { AIStatistics } from "./AIStatistics"; import { isNewInstrumentationUnitTest } from "../helpers/isNewInstrumentationUnitTest"; @@ -570,7 +570,7 @@ export class Agent { } onConnectHTTP(url: URL, port: number, method: string) { - triggerOutboundRequestHooks({ url, port, method }); + executeHooks("beforeOutboundRequest", { url, port, method }); } onRouteExecute(context: Context) { diff --git a/library/agent/hooks.test.ts b/library/agent/hooks.test.ts new file mode 100644 index 000000000..9eab2480f --- /dev/null +++ b/library/agent/hooks.test.ts @@ -0,0 +1,145 @@ +import * as t from "tap"; +import { addHook, removeHook, executeHooks, OutboundRequestInfo } from "./hooks"; + +t.test("it works", async (t) => { + let hookOneCalls = 0; + let hookTwoCalls = 0; + + const testRequest: OutboundRequestInfo = { + url: new URL("https://example.com"), + port: 443, + method: "GET", + }; + + function hook1(request: OutboundRequestInfo) { + t.equal(request.url.href, "https://example.com/"); + t.equal(request.port, 443); + t.equal(request.method, "GET"); + hookOneCalls++; + } + + function hook2(request: OutboundRequestInfo) { + t.equal(request.url.href, "https://example.com/"); + t.equal(request.port, 443); + t.equal(request.method, "GET"); + hookTwoCalls++; + } + + function hook3() { + throw new Error("hook3 should not be called"); + } + + t.same(hookOneCalls, 0, "hookOneCalls starts at 0"); + t.same(hookTwoCalls, 0, "hookTwoCalls starts at 0"); + + executeHooks("beforeOutboundRequest", testRequest); + + t.same(hookOneCalls, 0, "hookOneCalls still at 0"); + t.same(hookTwoCalls, 0, "hookTwoCalls still at 0"); + + addHook("beforeOutboundRequest", hook1); + // @ts-expect-error some other hook is not defined in the types + addHook("someOtherHook", hook3); + executeHooks("beforeOutboundRequest", testRequest); + + t.equal(hookOneCalls, 1, "hook1 called once"); + t.equal(hookTwoCalls, 0, "hook2 not called"); + + addHook("beforeOutboundRequest", hook2); + executeHooks("beforeOutboundRequest", testRequest); + + t.equal(hookOneCalls, 2, "hook1 called twice"); + t.equal(hookTwoCalls, 1, "hook2 called once"); + + removeHook("beforeOutboundRequest", hook1); + executeHooks("beforeOutboundRequest", testRequest); + + t.equal(hookOneCalls, 2, "hook1 still called twice"); + t.equal(hookTwoCalls, 2, "hook2 called twice"); + + removeHook("beforeOutboundRequest", hook2); + executeHooks("beforeOutboundRequest", testRequest); + + t.equal(hookOneCalls, 2, "hook1 still called twice"); + t.equal(hookTwoCalls, 2, "hook2 still called twice"); +}); + +t.test("it handles errors gracefully", async (t) => { + let successCalls = 0; + + function throwingHook() { + throw new Error("This should be caught"); + } + + function successHook() { + successCalls++; + } + + const testRequest: OutboundRequestInfo = { + url: new URL("https://example.com"), + port: 443, + method: "POST", + }; + + addHook("beforeOutboundRequest", throwingHook); + addHook("beforeOutboundRequest", successHook); + + // Should not throw even though one hook throws + t.doesNotThrow(() => { + executeHooks("beforeOutboundRequest", testRequest); + }); + + t.equal(successCalls, 1, "success hook still called despite error in other hook"); + + removeHook("beforeOutboundRequest", throwingHook); + removeHook("beforeOutboundRequest", successHook); +}); + +t.test("it handles async hooks with rejected promises", async (t) => { + let asyncCalls = 0; + + async function asyncHook() { + asyncCalls++; + throw new Error("Async error"); + } + + const testRequest: OutboundRequestInfo = { + url: new URL("https://example.com"), + port: 443, + method: "DELETE", + }; + + addHook("beforeOutboundRequest", asyncHook); + + // Should not throw even though async hook rejects + t.doesNotThrow(() => { + executeHooks("beforeOutboundRequest", testRequest); + }); + + t.equal(asyncCalls, 1, "async hook was called"); + + removeHook("beforeOutboundRequest", asyncHook); +}); + +t.test("it prevents duplicate hooks using Set", async (t) => { + let hookCalls = 0; + + function hook() { + hookCalls++; + } + + const testRequest: OutboundRequestInfo = { + url: new URL("https://example.com"), + port: 443, + method: "GET", + }; + + addHook("beforeOutboundRequest", hook); + addHook("beforeOutboundRequest", hook); // Try to add the same hook again + + executeHooks("beforeOutboundRequest", testRequest); + + t.equal(hookCalls, 1, "hook only called once despite being added twice"); + + removeHook("beforeOutboundRequest", hook); +}); diff --git a/library/agent/hooks.ts b/library/agent/hooks.ts new file mode 100644 index 000000000..318b5ffc7 --- /dev/null +++ b/library/agent/hooks.ts @@ -0,0 +1,60 @@ +export type OutboundRequestInfo = { + url: URL; + port: number; + method: string; +}; + +type HookName = "beforeOutboundRequest"; + +// Map hook names to argument types +interface HookTypes { + beforeOutboundRequest: { + args: [data: OutboundRequestInfo]; + }; +} + +const hooks = new Map< + HookName, + Set<(...args: HookTypes[HookName]["args"]) => void | Promise> +>(); + +export function addHook( + name: N, + fn: (...args: HookTypes[N]["args"]) => void | Promise +) { + if (!hooks.has(name)) { + hooks.set(name, new Set([fn])); + } else { + hooks.get(name)!.add(fn); + } +} + +export function removeHook( + name: N, + fn: (...args: HookTypes[N]["args"]) => void | Promise +) { + if (hooks.has(name)) { + hooks.get(name)!.delete(fn); + } +} + +export function executeHooks( + name: N, + ...args: [...HookTypes[N]["args"]] +): void { + const hookSet = hooks.get(name); + + for (const fn of hookSet ?? []) { + try { + const result = (fn as (...args: HookTypes[N]["args"]) => void | Promise)(...args); + // If it returns a promise, catch any errors but don't wait + if (result instanceof Promise) { + result.catch(() => { + // Silently ignore errors from user hooks + }); + } + } catch { + // Silently ignore errors from user hooks + } + } +} diff --git a/library/agent/hooks/outboundRequest.ts b/library/agent/hooks/outboundRequest.ts deleted file mode 100644 index 7df06dff3..000000000 --- a/library/agent/hooks/outboundRequest.ts +++ /dev/null @@ -1,41 +0,0 @@ -export type OutboundRequestInfo = { - url: URL; - port: number; - method: string; -}; - -type OutboundRequestCallback = ( - request: OutboundRequestInfo -) => void | Promise; - -const outboundRequestCallbacks = new Set(); - -export function onOutboundRequest(callback: OutboundRequestCallback): void { - if (typeof callback !== "function") { - throw new TypeError("Callback must be a function"); - } - - outboundRequestCallbacks.add(callback); -} - -export function triggerOutboundRequestHooks( - request: OutboundRequestInfo -): void { - if (outboundRequestCallbacks.size === 0) { - return; - } - - outboundRequestCallbacks.forEach((callback) => { - try { - const result = callback(request); - // If it returns a promise, catch any errors but don't wait - if (result instanceof Promise) { - result.catch(() => { - // Silently ignore errors from user hooks - }); - } - } catch { - // Silently ignore errors from user hooks - } - }); -} diff --git a/library/index.ts b/library/index.ts index 855219f18..794ba8c29 100644 --- a/library/index.ts +++ b/library/index.ts @@ -15,7 +15,7 @@ import { isESM } from "./helpers/isESM"; import { checkIndexImportGuard } from "./helpers/indexImportGuard"; import { setRateLimitGroup } from "./ratelimiting/group"; import { isLibBundled } from "./helpers/isLibBundled"; -import { onOutboundRequest } from "./agent/hooks/outboundRequest"; +import { addHook, removeHook } from "./agent/hooks"; // Prevent logging twice / trying to start agent twice if (!isNewHookSystemUsed()) { @@ -52,7 +52,8 @@ export { addKoaMiddleware, addRestifyMiddleware, setRateLimitGroup, - onOutboundRequest, + addHook, + removeHook, }; // Required for ESM / TypeScript default export support @@ -69,5 +70,6 @@ export default { addKoaMiddleware, addRestifyMiddleware, setRateLimitGroup, - onOutboundRequest, + addHook, + removeHook, }; From 0282d36bd789c3bd9f35e2847944ef2c86f625ae Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Thu, 4 Dec 2025 15:57:04 +0100 Subject: [PATCH 04/14] Add hooks support for Undici Note: { hostname: '...', port: '...' } is not supported by Undici (hence why I removed the test case) --- library/agent/Agent.ts | 6 +- library/sinks/Undici.tests.ts | 25 ++++ library/sinks/Undici.ts | 36 +++-- .../sinks/undici/buildURLFromObject.test.ts | 128 ++++++++++++++++++ library/sinks/undici/buildURLFromObject.ts | 46 +++++++ .../undici/getHostnameAndPortFromArgs.test.ts | 21 ++- .../undici/getHostnameAndPortFromArgs.ts | 95 ------------- 7 files changed, 242 insertions(+), 115 deletions(-) create mode 100644 library/sinks/undici/buildURLFromObject.test.ts create mode 100644 library/sinks/undici/buildURLFromObject.ts delete mode 100644 library/sinks/undici/getHostnameAndPortFromArgs.ts diff --git a/library/agent/Agent.ts b/library/agent/Agent.ts index 6114144f3..0a79a98d8 100644 --- a/library/agent/Agent.ts +++ b/library/agent/Agent.ts @@ -570,7 +570,11 @@ export class Agent { } onConnectHTTP(url: URL, port: number, method: string) { - executeHooks("beforeOutboundRequest", { url, port, method }); + executeHooks("beforeOutboundRequest", { + url, + port, + method: method.toUpperCase(), + }); } onRouteExecute(context: Context) { diff --git a/library/sinks/Undici.tests.ts b/library/sinks/Undici.tests.ts index 4f2acac73..b57ded23c 100644 --- a/library/sinks/Undici.tests.ts +++ b/library/sinks/Undici.tests.ts @@ -2,6 +2,7 @@ import * as t from "tap"; import { ReportingAPIForTesting } from "../agent/api/ReportingAPIForTesting"; import { Token } from "../agent/api/Token"; import { Context, runWithContext } from "../agent/Context"; +import { addHook, removeHook } from "../agent/hooks"; import { LoggerForTesting } from "../agent/logger/LoggerForTesting"; import { startTestAgent } from "../helpers/startTestAgent"; import { getMajorNodeVersion } from "../helpers/getNodeVersion"; @@ -71,6 +72,30 @@ export async function createUndiciTests(undiciPkgName: string, port: number) { undiciPkgName ) as typeof import("undici-v6"); + const hookArgs: unknown[] = []; + const beforeOutbound = (args: unknown) => { + hookArgs.push(args); + }; + addHook("beforeOutboundRequest", beforeOutbound); + await request({ + protocol: "https:", + hostname: "ssrf-redirects.testssandbox.com", + pathname: "/my-path", + search: "?a=b", + }); + t.same(agent.getHostnames().asArray(), [ + { hostname: "ssrf-redirects.testssandbox.com", port: 443, hits: 1 }, + ]); + agent.getHostnames().clear(); + t.same(hookArgs, [ + { + url: new URL("https://ssrf-redirects.testssandbox.com/my-path?a=b"), + method: "GET", + port: 443, + }, + ]); + removeHook("beforeOutboundRequest", beforeOutbound); + await request("https://ssrf-redirects.testssandbox.com"); t.same(agent.getHostnames().asArray(), [ { hostname: "ssrf-redirects.testssandbox.com", port: 443, hits: 1 }, diff --git a/library/sinks/Undici.ts b/library/sinks/Undici.ts index 1c9fef3b6..357999f7e 100644 --- a/library/sinks/Undici.ts +++ b/library/sinks/Undici.ts @@ -6,12 +6,14 @@ import { Hooks } from "../agent/hooks/Hooks"; import { InterceptorResult } from "../agent/hooks/InterceptorResult"; import { Wrapper } from "../agent/Wrapper"; import { getSemverNodeVersion } from "../helpers/getNodeVersion"; +import { getPortFromURL } from "../helpers/getPortFromURL"; +import { isPlainObject } from "../helpers/isPlainObject"; import { isVersionGreaterOrEqual } from "../helpers/isVersionGreaterOrEqual"; import { checkContextForSSRF } from "../vulnerabilities/ssrf/checkContextForSSRF"; import { inspectDNSLookupCalls } from "../vulnerabilities/ssrf/inspectDNSLookupCalls"; +import { buildURLFromArgs } from "./undici/buildURLFromObject"; import { wrapDispatch } from "./undici/wrapDispatch"; import { wrapExport } from "../agent/hooks/wrapExport"; -import { getHostnameAndPortFromArgs } from "./undici/getHostnameAndPortFromArgs"; import type { PartialWrapPackageInfo } from "../agent/hooks/WrapPackageInfo"; const methods = [ @@ -26,14 +28,16 @@ const methods = [ export class Undici implements Wrapper { private inspectHostname( agent: Agent, - hostname: string, - port: number | undefined, - method: string + url: URL, + method: string, + httpMethod: string ): InterceptorResult { // Let the agent know that we are connecting to this hostname // This is to build a list of all hostnames that the application is connecting to + const port = getPortFromURL(url); if (typeof port === "number" && port > 0) { - agent.onConnectHostname(hostname, port); + agent.onConnectHostname(url.hostname, port); + agent.onConnectHTTP(url, port, httpMethod); } const context = getContext(); @@ -42,7 +46,7 @@ export class Undici implements Wrapper { } return checkContextForSSRF({ - hostname: hostname, + hostname: url.hostname, operation: `undici.${method}`, context, port, @@ -54,14 +58,18 @@ export class Undici implements Wrapper { agent: Agent, method: string ): InterceptorResult { - const hostnameAndPort = getHostnameAndPortFromArgs(args); - if (hostnameAndPort) { - const attack = this.inspectHostname( - agent, - hostnameAndPort.hostname, - hostnameAndPort.port, - method - ); + let httpMethod = "GET"; + if ( + args.length > 1 && + isPlainObject(args[1]) && + typeof args[1].method === "string" + ) { + httpMethod = args[1].method; + } + + const url = buildURLFromArgs(args); + if (url) { + const attack = this.inspectHostname(agent, url, method, httpMethod); if (attack) { return attack; } diff --git a/library/sinks/undici/buildURLFromObject.test.ts b/library/sinks/undici/buildURLFromObject.test.ts new file mode 100644 index 000000000..02ba4e6a0 --- /dev/null +++ b/library/sinks/undici/buildURLFromObject.test.ts @@ -0,0 +1,128 @@ +import * as t from "tap"; +import { buildURLFromArgs } from "./buildURLFromObject"; +import { parse as parseUrl } from "url"; + +t.test("it returns an URL instance", async (t) => { + const url = buildURLFromArgs(["http://localhost:4000"]); + t.ok(url instanceof URL); +}); + +t.test("it returns the full url", async () => { + t.same( + buildURLFromArgs([ + { origin: "http://localhost:4000", pathname: "/api", search: "?page=1" }, + ])?.toString(), + "http://localhost:4000/api?page=1" + ); + t.same( + buildURLFromArgs([ + { origin: "http://localhost:4000", path: "/api?page=1" }, + ])?.toString(), + "http://localhost:4000/api?page=1" + ); +}); + +t.test("it works with url string", async (t) => { + t.same( + buildURLFromArgs(["http://localhost:4000"])?.toString(), + "http://localhost:4000/" + ); + t.same( + buildURLFromArgs(["http://localhost?test=1"])?.toString(), + "http://localhost/?test=1" + ); + t.same( + buildURLFromArgs(["https://localhost"])?.toString(), + "https://localhost/" + ); +}); + +t.test("it works with url object", async (t) => { + t.same( + buildURLFromArgs([new URL("http://localhost:4000")])?.toString(), + "http://localhost:4000/" + ); + t.same( + buildURLFromArgs([new URL("http://localhost?test=1")])?.toString(), + "http://localhost/?test=1" + ); + t.same( + buildURLFromArgs([new URL("https://localhost")])?.toString(), + "https://localhost/" + ); +}); + +t.test("it works with an array of strings", async (t) => { + t.same( + buildURLFromArgs([["http://localhost:4000"]])?.toString(), + "http://localhost:4000/" + ); + t.same( + buildURLFromArgs([["http://localhost?test=1"]])?.toString(), + "http://localhost/?test=1" + ); + t.same( + buildURLFromArgs([["https://localhost"]])?.toString(), + "https://localhost/" + ); +}); + +t.test("it works with an legacy url object", async (t) => { + t.same( + buildURLFromArgs([parseUrl("http://localhost:4000")])?.toString(), + "http://localhost:4000/" + ); + t.same( + buildURLFromArgs([parseUrl("http://localhost?test=1")])?.toString(), + "http://localhost/?test=1" + ); + t.same( + buildURLFromArgs([parseUrl("https://localhost")])?.toString(), + "https://localhost/" + ); +}); + +t.test("it works with an options object containing origin", async (t) => { + t.same( + buildURLFromArgs([{ origin: "http://localhost:4000" }])?.toString(), + "http://localhost:4000/" + ); + t.same( + buildURLFromArgs([ + { origin: "http://localhost", search: "?test=1" }, + ])?.toString(), + "http://localhost/?test=1" + ); + t.same( + buildURLFromArgs([{ origin: "https://localhost" }])?.toString(), + "https://localhost/" + ); +}); + +t.test( + "it works with an options object containing protocol, hostname and port", + async (t) => { + t.same( + buildURLFromArgs([ + { protocol: "http:", hostname: "localhost", port: 4000 }, + ])?.toString(), + "http://localhost:4000/" + ); + t.same( + buildURLFromArgs([ + { protocol: "https:", hostname: "localhost" }, + ])?.toString(), + "https://localhost/" + ); + } +); + +t.test("invalid origin url", async (t) => { + t.same(buildURLFromArgs([{ origin: "invalid url" }]), undefined); + t.same(buildURLFromArgs([{ origin: "" }]), undefined); +}); + +t.test("without hostname", async (t) => { + t.same(buildURLFromArgs([{}]), undefined); + t.same(buildURLFromArgs([{ protocol: "https:", port: 4000 }]), undefined); +}); diff --git a/library/sinks/undici/buildURLFromObject.ts b/library/sinks/undici/buildURLFromObject.ts new file mode 100644 index 000000000..6201879a7 --- /dev/null +++ b/library/sinks/undici/buildURLFromObject.ts @@ -0,0 +1,46 @@ +import { tryParseURL } from "../../helpers/tryParseURL"; +import { isOptionsObject } from "../http-request/isOptionsObject"; + +export function buildURLFromArgs(args: unknown[]) { + if (args.length === 0) { + return undefined; + } + + if (typeof args[0] === "string") { + return tryParseURL(args[0]); + } + + if (Array.isArray(args[0])) { + return tryParseURL(args[0].toString()); + } + + if (args[0] instanceof URL) { + return args[0]; + } + + if (isOptionsObject(args[0])) { + return buildURLFromObject(args[0] as Record); + } + + return undefined; +} + +// Logic copied from parseURL in https://github.com/nodejs/undici/blob/main/lib/core/util.js +// Note: { hostname: string, port: number } is not accepted by Undici +function buildURLFromObject(url: Record) { + const port = url.port ? url.port : url.protocol === "https:" ? 443 : 80; + + let origin = url.origin + ? url.origin + : `${url.protocol || ""}//${url.hostname || ""}:${port}`; + if (typeof origin === "string" && origin[origin.length - 1] === "/") { + origin = origin.slice(0, origin.length - 1); + } + + let path = url.path ? url.path : `${url.pathname || ""}${url.search || ""}`; + if (typeof path === "string" && path[0] !== "/") { + path = `/${path}`; + } + + return tryParseURL(`${origin}${path}`); +} diff --git a/library/sinks/undici/getHostnameAndPortFromArgs.test.ts b/library/sinks/undici/getHostnameAndPortFromArgs.test.ts index b5b1bfcde..135e27d78 100644 --- a/library/sinks/undici/getHostnameAndPortFromArgs.test.ts +++ b/library/sinks/undici/getHostnameAndPortFromArgs.test.ts @@ -1,7 +1,22 @@ +// There used to be a function named `getHostnameAndPortFromArgs`, we kept the tests for it! import * as t from "tap"; -import { getHostnameAndPortFromArgs as get } from "./getHostnameAndPortFromArgs"; +import { getPortFromURL } from "../../helpers/getPortFromURL"; +import { buildURLFromArgs } from "./buildURLFromObject"; import { parse as parseUrl } from "url"; +function get(args: unknown[]) { + const url = buildURLFromArgs(args); + + if (url) { + return { + hostname: url.hostname, + port: getPortFromURL(url), + }; + } + + return undefined; +} + t.test("it works with url string", async (t) => { t.same(get(["http://localhost:4000"]), { hostname: "localhost", @@ -84,10 +99,6 @@ t.test( hostname: "localhost", port: 4000, }); - t.same(get([{ hostname: "localhost", port: 4000 }]), { - hostname: "localhost", - port: 4000, - }); t.same(get([{ protocol: "https:", hostname: "localhost" }]), { hostname: "localhost", port: 443, diff --git a/library/sinks/undici/getHostnameAndPortFromArgs.ts b/library/sinks/undici/getHostnameAndPortFromArgs.ts deleted file mode 100644 index e27c848c9..000000000 --- a/library/sinks/undici/getHostnameAndPortFromArgs.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { getPortFromURL } from "../../helpers/getPortFromURL"; -import { tryParseURL } from "../../helpers/tryParseURL"; -import { isOptionsObject } from "../http-request/isOptionsObject"; - -type HostnameAndPort = { - hostname: string; - port: number | undefined; -}; - -/** - * Extract hostname and port from the arguments of a undici request. - * Used for SSRF detection. - */ -export function getHostnameAndPortFromArgs( - args: unknown[] -): HostnameAndPort | undefined { - let url: URL | undefined; - if (args.length > 0) { - // URL provided as a string - if (typeof args[0] === "string" && args[0].length > 0) { - url = tryParseURL(args[0]); - } - // Fetch accepts any object with a stringifier. User input may be an array if the user provides an array - // query parameter (e.g., ?example[0]=https://example.com/) in frameworks like Express. Since an Array has - // a default stringifier, this is exploitable in a default setup. - // The following condition ensures that we see the same value as what's passed down to the sink. - if (Array.isArray(args[0])) { - url = tryParseURL(args[0].toString()); - } - - // URL provided as a URL object - if (args[0] instanceof URL) { - url = args[0]; - } - - // If url is not undefined, extract the hostname and port - if (url && url.hostname.length > 0) { - return { - hostname: url.hostname, - port: getPortFromURL(url), - }; - } - - // Check if it can be a request options object - if (isOptionsObject(args[0])) { - return parseOptionsObject(args[0]); - } - } - - return undefined; -} - -/** - * Parse a undici request options object to extract hostname and port. - */ -function parseOptionsObject(obj: any): HostnameAndPort | undefined { - // Origin is preferred over hostname - // See https://github.com/nodejs/undici/blob/c926a43ac5952b8b5a6c7d15529b56599bc1b762/lib/core/util.js#L177 - // oxlint-disable-next-line eqeqeq - if (obj.origin != null && typeof obj.origin === "string") { - const url = tryParseURL(obj.origin); - if (url) { - return { - hostname: url.hostname, - port: getPortFromURL(url), - }; - } - - // Undici should throw an error if the origin is not a valid URL - return undefined; - } - - let port = 80; - if (typeof obj.protocol === "string") { - port = obj.protocol === "https:" ? 443 : 80; - } - if (typeof obj.port === "number") { - port = obj.port; - } else if ( - typeof obj.port === "string" && - Number.isInteger(parseInt(obj.port, 10)) - ) { - port = parseInt(obj.port, 10); - } - - // hostname is required by undici and host is not supported - if (typeof obj.hostname !== "string" || obj.hostname.length === 0) { - return undefined; - } - - return { - hostname: obj.hostname, - port, - }; -} From b9d63e00c3d50cd93316f6c1161e298a68127d11 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Thu, 4 Dec 2025 16:09:21 +0100 Subject: [PATCH 05/14] Fix typo --- library/sinks/http-request/isOptionsObject.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/sinks/http-request/isOptionsObject.ts b/library/sinks/http-request/isOptionsObject.ts index facfca768..206a7d2e4 100644 --- a/library/sinks/http-request/isOptionsObject.ts +++ b/library/sinks/http-request/isOptionsObject.ts @@ -1,6 +1,6 @@ /** * Check if the argument is treated as an options object by Node.js. - * For checking if the argument can be used as options for a outgoing HTTP request. + * For checking if the argument can be used as options for an outgoing HTTP request. */ export function isOptionsObject(arg: any): arg is { [key: string]: unknown } { return ( From 552aaef3c051fa421e162a8b6ffb16cd1797f4af Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Thu, 4 Dec 2025 16:09:29 +0100 Subject: [PATCH 06/14] Re-add comment --- library/sinks/undici/buildURLFromObject.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/library/sinks/undici/buildURLFromObject.ts b/library/sinks/undici/buildURLFromObject.ts index 6201879a7..dd0b1695b 100644 --- a/library/sinks/undici/buildURLFromObject.ts +++ b/library/sinks/undici/buildURLFromObject.ts @@ -10,6 +10,11 @@ export function buildURLFromArgs(args: unknown[]) { return tryParseURL(args[0]); } + // undici also exports `fetch` (like the global fetch) + // Fetch accepts any object with a stringifier. User input may be an array if the user provides an array + // query parameter (e.g., ?example[0]=https://example.com/) in frameworks like Express. Since an Array has + // a default stringifier, this is exploitable in a default setup. + // The following condition ensures that we see the same value as what's passed down to the sink. if (Array.isArray(args[0])) { return tryParseURL(args[0].toString()); } From 59e4df5489f33db5876a8100089b2ca4afb676c4 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Thu, 4 Dec 2025 16:16:09 +0100 Subject: [PATCH 07/14] Add test for fetch --- library/sinks/Fetch.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/library/sinks/Fetch.test.ts b/library/sinks/Fetch.test.ts index 850fd87bd..b17a93146 100644 --- a/library/sinks/Fetch.test.ts +++ b/library/sinks/Fetch.test.ts @@ -3,6 +3,7 @@ import * as t from "tap"; import { ReportingAPIForTesting } from "../agent/api/ReportingAPIForTesting"; import { Token } from "../agent/api/Token"; import { Context, runWithContext } from "../agent/Context"; +import { addHook, removeHook } from "../agent/hooks"; import { wrap } from "../helpers/wrap"; import { Fetch } from "./Fetch"; import * as dns from "dns"; @@ -92,12 +93,24 @@ t.test( t.same(agent.getHostnames().asArray(), []); + const hookArgs: unknown[] = []; + const hook = (args: unknown) => { + hookArgs.push(args); + }; + addHook("beforeOutboundRequest", hook); await fetch("http://app.aikido.dev"); - t.same(agent.getHostnames().asArray(), [ { hostname: "app.aikido.dev", port: 80, hits: 1 }, ]); agent.getHostnames().clear(); + t.same(hookArgs, [ + { + url: new URL("http://app.aikido.dev"), + method: "GET", + port: 80, + }, + ]); + removeHook("beforeOutboundRequest", hook); await fetch(new URL("https://app.aikido.dev")); From 133337b28cd4c575841927d31b84abee7e7cadb4 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Thu, 4 Dec 2025 16:16:16 +0100 Subject: [PATCH 08/14] Fix linting errors --- library/sinks/undici/buildURLFromObject.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/library/sinks/undici/buildURLFromObject.ts b/library/sinks/undici/buildURLFromObject.ts index dd0b1695b..0d81cb432 100644 --- a/library/sinks/undici/buildURLFromObject.ts +++ b/library/sinks/undici/buildURLFromObject.ts @@ -1,3 +1,5 @@ +// oxlint-disable typescript-eslint(no-base-to-string) +// oxlint-disable typescript-eslint(restrict-template-expressions) import { tryParseURL } from "../../helpers/tryParseURL"; import { isOptionsObject } from "../http-request/isOptionsObject"; From 4858e922906f63e3482781877fcb2573be88d77a Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Thu, 4 Dec 2025 16:17:23 +0100 Subject: [PATCH 09/14] Remove usage of doesNotThrow Not supported when converting to builtin node assertions --- library/agent/hooks.test.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/library/agent/hooks.test.ts b/library/agent/hooks.test.ts index 9eab2480f..5288674e7 100644 --- a/library/agent/hooks.test.ts +++ b/library/agent/hooks.test.ts @@ -1,5 +1,10 @@ import * as t from "tap"; -import { addHook, removeHook, executeHooks, OutboundRequestInfo } from "./hooks"; +import { + addHook, + removeHook, + executeHooks, + OutboundRequestInfo, +} from "./hooks"; t.test("it works", async (t) => { let hookOneCalls = 0; @@ -85,11 +90,13 @@ t.test("it handles errors gracefully", async (t) => { addHook("beforeOutboundRequest", successHook); // Should not throw even though one hook throws - t.doesNotThrow(() => { - executeHooks("beforeOutboundRequest", testRequest); - }); + executeHooks("beforeOutboundRequest", testRequest); - t.equal(successCalls, 1, "success hook still called despite error in other hook"); + t.equal( + successCalls, + 1, + "success hook still called despite error in other hook" + ); removeHook("beforeOutboundRequest", throwingHook); removeHook("beforeOutboundRequest", successHook); @@ -112,9 +119,7 @@ t.test("it handles async hooks with rejected promises", async (t) => { addHook("beforeOutboundRequest", asyncHook); // Should not throw even though async hook rejects - t.doesNotThrow(() => { - executeHooks("beforeOutboundRequest", testRequest); - }); + executeHooks("beforeOutboundRequest", testRequest); t.equal(asyncCalls, 1, "async hook was called"); From 00c39d7a9ced3cffb47ad379fe496705307838cd Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Thu, 4 Dec 2025 16:48:46 +0100 Subject: [PATCH 10/14] Add test for http.request/get --- library/sinks/HTTPRequest.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/library/sinks/HTTPRequest.test.ts b/library/sinks/HTTPRequest.test.ts index 58572a4b8..0ed8efad9 100644 --- a/library/sinks/HTTPRequest.test.ts +++ b/library/sinks/HTTPRequest.test.ts @@ -3,6 +3,7 @@ import * as dns from "dns"; import * as t from "tap"; import { Token } from "../agent/api/Token"; import { Context, runWithContext } from "../agent/Context"; +import { addHook, removeHook } from "../agent/hooks"; import { wrap } from "../helpers/wrap"; import { HTTPRequest } from "./HTTPRequest"; import { createTestAgent } from "../helpers/createTestAgent"; @@ -74,15 +75,27 @@ const oldUrl = require("url"); t.test("it works", (t) => { t.same(agent.getHostnames().asArray(), []); + const hookArgs: unknown[] = []; + const hook = (args: unknown) => { + hookArgs.push(args); + }; + addHook("beforeOutboundRequest", hook); runWithContext(createContext(), () => { const aikido = http.request("http://aikido.dev"); aikido.end(); }); - t.same(agent.getHostnames().asArray(), [ { hostname: "aikido.dev", port: 80, hits: 1 }, ]); agent.getHostnames().clear(); + t.same(hookArgs, [ + { + url: new URL("http://aikido.dev/"), + port: 80, + method: "GET", + }, + ]); + removeHook("beforeOutboundRequest", hook); runWithContext(createContext(), () => { const aikido = https.request("https://aikido.dev"); From f0cd16c7b592823d2c88ad2aa24c33dffd8b6dad Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Thu, 4 Dec 2025 16:55:37 +0100 Subject: [PATCH 11/14] Format code --- library/agent/hooks.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/library/agent/hooks.ts b/library/agent/hooks.ts index 318b5ffc7..cdb5110b7 100644 --- a/library/agent/hooks.ts +++ b/library/agent/hooks.ts @@ -46,7 +46,9 @@ export function executeHooks( for (const fn of hookSet ?? []) { try { - const result = (fn as (...args: HookTypes[N]["args"]) => void | Promise)(...args); + const result = ( + fn as (...args: HookTypes[N]["args"]) => void | Promise + )(...args); // If it returns a promise, catch any errors but don't wait if (result instanceof Promise) { result.catch(() => { From 91c09e3c482a4f38e8c2b5110bc060a2281028c8 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Thu, 4 Dec 2025 17:28:09 +0100 Subject: [PATCH 12/14] Simplify --- library/agent/hooks.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/library/agent/hooks.ts b/library/agent/hooks.ts index cdb5110b7..a43ba04d8 100644 --- a/library/agent/hooks.ts +++ b/library/agent/hooks.ts @@ -33,9 +33,7 @@ export function removeHook( name: N, fn: (...args: HookTypes[N]["args"]) => void | Promise ) { - if (hooks.has(name)) { - hooks.get(name)!.delete(fn); - } + hooks.get(name)?.delete(fn); } export function executeHooks( From a3613f33409867153cb855e8b2346b2ad199f2bc Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 5 Dec 2025 09:51:48 +0100 Subject: [PATCH 13/14] Improve test coverage --- library/sinks/Fetch.test.ts | 7 +++++++ library/sinks/Undici.tests.ts | 9 +++++++++ .../sinks/undici/buildURLFromObject.test.ts | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/library/sinks/Fetch.test.ts b/library/sinks/Fetch.test.ts index b17a93146..7f1c552aa 100644 --- a/library/sinks/Fetch.test.ts +++ b/library/sinks/Fetch.test.ts @@ -99,8 +99,10 @@ t.test( }; addHook("beforeOutboundRequest", hook); await fetch("http://app.aikido.dev"); + await fetch(new Request("https://app.aikido.dev", { method: "POST" })); t.same(agent.getHostnames().asArray(), [ { hostname: "app.aikido.dev", port: 80, hits: 1 }, + { hostname: "app.aikido.dev", port: 443, hits: 1 }, ]); agent.getHostnames().clear(); t.same(hookArgs, [ @@ -109,6 +111,11 @@ t.test( method: "GET", port: 80, }, + { + url: new URL("https://app.aikido.dev/"), + method: "POST", + port: 443, + }, ]); removeHook("beforeOutboundRequest", hook); diff --git a/library/sinks/Undici.tests.ts b/library/sinks/Undici.tests.ts index b57ded23c..536f26ac4 100644 --- a/library/sinks/Undici.tests.ts +++ b/library/sinks/Undici.tests.ts @@ -83,8 +83,12 @@ export async function createUndiciTests(undiciPkgName: string, port: number) { pathname: "/my-path", search: "?a=b", }); + await request(`http://localhost:${port}/api/internal`, { + method: "POST", + }); t.same(agent.getHostnames().asArray(), [ { hostname: "ssrf-redirects.testssandbox.com", port: 443, hits: 1 }, + { hostname: "localhost", port: port, hits: 1 }, ]); agent.getHostnames().clear(); t.same(hookArgs, [ @@ -93,6 +97,11 @@ export async function createUndiciTests(undiciPkgName: string, port: number) { method: "GET", port: 443, }, + { + url: new URL(`http://localhost:${port}/api/internal`), + method: "POST", + port: port, + }, ]); removeHook("beforeOutboundRequest", beforeOutbound); diff --git a/library/sinks/undici/buildURLFromObject.test.ts b/library/sinks/undici/buildURLFromObject.test.ts index 02ba4e6a0..6582f35ee 100644 --- a/library/sinks/undici/buildURLFromObject.test.ts +++ b/library/sinks/undici/buildURLFromObject.test.ts @@ -2,6 +2,11 @@ import * as t from "tap"; import { buildURLFromArgs } from "./buildURLFromObject"; import { parse as parseUrl } from "url"; +t.test("empty", async (t) => { + const url = buildURLFromArgs([]); + t.same(url, undefined); +}); + t.test("it returns an URL instance", async (t) => { const url = buildURLFromArgs(["http://localhost:4000"]); t.ok(url instanceof URL); @@ -22,6 +27,21 @@ t.test("it returns the full url", async () => { ); }); +t.test("origin ends with slash", async (t) => { + t.same( + buildURLFromArgs([ + { origin: "http://localhost:4000/", pathname: "/api", search: "?page=1" }, + ])?.toString(), + "http://localhost:4000/api?page=1" + ); + t.same( + buildURLFromArgs([ + { origin: "http://localhost:4000/", path: "/api?page=1" }, + ])?.toString(), + "http://localhost:4000/api?page=1" + ); +}); + t.test("it works with url string", async (t) => { t.same( buildURLFromArgs(["http://localhost:4000"])?.toString(), From 3d5cb217f086f147ae4ff440b27b4e0050cee0cb Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 8 Dec 2025 09:39:40 +0100 Subject: [PATCH 14/14] Fix merge --- library/sinks/Fetch.ts | 4 ++-- library/sinks/Undici.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/library/sinks/Fetch.ts b/library/sinks/Fetch.ts index f410a47b7..39eae204c 100644 --- a/library/sinks/Fetch.ts +++ b/library/sinks/Fetch.ts @@ -26,10 +26,10 @@ export class Fetch implements Wrapper { agent.onConnectHTTP(url, port, method); } - if (agent.getConfig().shouldBlockOutgoingRequest(hostname)) { + if (agent.getConfig().shouldBlockOutgoingRequest(url.hostname)) { return { operation: "fetch", - hostname: hostname, + hostname: url.hostname, }; } diff --git a/library/sinks/Undici.ts b/library/sinks/Undici.ts index ce8768a0c..4ac3815f3 100644 --- a/library/sinks/Undici.ts +++ b/library/sinks/Undici.ts @@ -40,10 +40,10 @@ export class Undici implements Wrapper { agent.onConnectHTTP(url, port, httpMethod); } - if (agent.getConfig().shouldBlockOutgoingRequest(hostname)) { + if (agent.getConfig().shouldBlockOutgoingRequest(url.hostname)) { return { operation: `undici.${method}`, - hostname: hostname, + hostname: url.hostname, }; }