Skip to content
Open
39 changes: 39 additions & 0 deletions docs/outbound-requests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Monitoring Outbound Requests

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 { addHook } = require("@aikidosec/firewall");

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 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.
9 changes: 9 additions & 0 deletions library/agent/Agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { wrapInstalledPackages } from "./wrapInstalledPackages";
import { Wrapper } from "./Wrapper";
import { isAikidoCI } from "../helpers/isAikidoCI";
import { AttackLogger } from "./AttackLogger";
import { executeHooks } from "./hooks";
import { Packages } from "./Packages";
import { AIStatistics } from "./AIStatistics";
import { isNewInstrumentationUnitTest } from "../helpers/isNewInstrumentationUnitTest";
Expand Down Expand Up @@ -579,6 +580,14 @@ export class Agent {
this.hostnames.add(hostname, port);
}

onConnectHTTP(url: URL, port: number, method: string) {
executeHooks("beforeOutboundRequest", {
url,
port,
method: method.toUpperCase(),
});
}

onRouteExecute(context: Context) {
this.routes.addRoute(context);
}
Expand Down
150 changes: 150 additions & 0 deletions library/agent/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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
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
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);
});
60 changes: 60 additions & 0 deletions library/agent/hooks.ts
Original file line number Diff line number Diff line change
@@ -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<void>>
>();

export function addHook<N extends HookName>(
name: N,
fn: (...args: HookTypes[N]["args"]) => void | Promise<void>
) {
if (!hooks.has(name)) {
hooks.set(name, new Set([fn]));
} else {
hooks.get(name)!.add(fn);
}
}

export function removeHook<N extends HookName>(
name: N,
fn: (...args: HookTypes[N]["args"]) => void | Promise<void>
) {
hooks.get(name)?.delete(fn);
}

export function executeHooks<N extends HookName>(
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<void>
)(...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
}
}
}
5 changes: 5 additions & 0 deletions library/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { addHook, removeHook } from "./agent/hooks";

// Prevent logging twice / trying to start agent twice
if (!isNewHookSystemUsed()) {
Expand Down Expand Up @@ -51,6 +52,8 @@ export {
addKoaMiddleware,
addRestifyMiddleware,
setRateLimitGroup,
addHook,
removeHook,
};

// Required for ESM / TypeScript default export support
Expand All @@ -67,4 +70,6 @@ export default {
addKoaMiddleware,
addRestifyMiddleware,
setRateLimitGroup,
addHook,
removeHook,
};
22 changes: 21 additions & 1 deletion library/sinks/Fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -92,12 +93,31 @@ 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");

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, [
{
url: new URL("http://app.aikido.dev"),
method: "GET",
port: 80,
},
{
url: new URL("https://app.aikido.dev/"),
method: "POST",
port: 443,
},
]);
removeHook("beforeOutboundRequest", hook);

await fetch(new URL("https://app.aikido.dev"));

Expand Down
Loading