Skip to content

Commit 2f41e4b

Browse files
committed
Add hook to monitor outbound requests
1 parent a58016d commit 2f41e4b

File tree

7 files changed

+113
-29
lines changed

7 files changed

+113
-29
lines changed

docs/outbound-requests.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Monitoring Outbound Requests
2+
3+
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.
4+
5+
## Basic Usage
6+
7+
```js
8+
const { onOutboundRequest } = require("@aikidosec/firewall");
9+
10+
onOutboundRequest(({ url, port, method }) => {
11+
// url is a URL object: https://nodejs.org/api/url.html#class-url
12+
console.log(`${new Date().toISOString()} - ${method} ${url.href}`);
13+
});
14+
```
15+
16+
## Important Notes
17+
18+
- You can register multiple callbacks by calling `onOutboundRequest` multiple times.
19+
- 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.
20+
- Callbacks are called when the connection is initiated, before knowing if Zen will block the request.
21+
- Errors thrown in callbacks (both sync and async) are silently caught and not logged to prevent breaking your application.

library/agent/Agent.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { wrapInstalledPackages } from "./wrapInstalledPackages";
2727
import { Wrapper } from "./Wrapper";
2828
import { isAikidoCI } from "../helpers/isAikidoCI";
2929
import { AttackLogger } from "./AttackLogger";
30+
import { triggerOutboundRequestHooks } from "./hooks/outboundRequest";
3031
import { Packages } from "./Packages";
3132
import { AIStatistics } from "./AIStatistics";
3233
import { isNewInstrumentationUnitTest } from "../helpers/isNewInstrumentationUnitTest";
@@ -564,8 +565,12 @@ export class Agent {
564565
}
565566
}
566567

567-
onConnectHostname(hostname: string, port: number) {
568-
this.hostnames.add(hostname, port);
568+
onConnectHostname(url: URL, port: number) {
569+
this.hostnames.add(url.hostname, port);
570+
}
571+
572+
onConnectHTTP(url: URL, port: number, method: string) {
573+
triggerOutboundRequestHooks({ url, port, method });
569574
}
570575

571576
onRouteExecute(context: Context) {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export type OutboundRequestInfo = {
2+
url: URL;
3+
port: number;
4+
method: string;
5+
};
6+
7+
type OutboundRequestCallback = (
8+
request: OutboundRequestInfo
9+
) => void | Promise<void>;
10+
11+
const outboundRequestCallbacks = new Set<OutboundRequestCallback>();
12+
13+
export function onOutboundRequest(callback: OutboundRequestCallback): void {
14+
if (typeof callback !== "function") {
15+
throw new TypeError("Callback must be a function");
16+
}
17+
18+
outboundRequestCallbacks.add(callback);
19+
}
20+
21+
export function triggerOutboundRequestHooks(
22+
request: OutboundRequestInfo
23+
): void {
24+
if (outboundRequestCallbacks.size === 0) {
25+
return;
26+
}
27+
28+
outboundRequestCallbacks.forEach((callback) => {
29+
try {
30+
const result = callback(request);
31+
// If it returns a promise, catch any errors but don't wait
32+
if (result instanceof Promise) {
33+
result.catch(() => {
34+
// Silently ignore errors from user hooks
35+
});
36+
}
37+
} catch {
38+
// Silently ignore errors from user hooks
39+
}
40+
});
41+
}

library/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { isESM } from "./helpers/isESM";
1515
import { checkIndexImportGuard } from "./helpers/indexImportGuard";
1616
import { setRateLimitGroup } from "./ratelimiting/group";
1717
import { isLibBundled } from "./helpers/isLibBundled";
18+
import { onOutboundRequest } from "./agent/hooks/outboundRequest";
1819

1920
// Prevent logging twice / trying to start agent twice
2021
if (!isNewHookSystemUsed()) {
@@ -51,6 +52,7 @@ export {
5152
addKoaMiddleware,
5253
addRestifyMiddleware,
5354
setRateLimitGroup,
55+
onOutboundRequest,
5456
};
5557

5658
// Required for ESM / TypeScript default export support
@@ -67,4 +69,5 @@ export default {
6769
addKoaMiddleware,
6870
addRestifyMiddleware,
6971
setRateLimitGroup,
72+
onOutboundRequest,
7073
};

library/sinks/Fetch.ts

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ export class Fetch implements Wrapper {
1616

1717
private inspectHostname(
1818
agent: Agent,
19-
hostname: string,
20-
port: number | undefined
19+
url: URL,
20+
port: number | undefined,
21+
method: string
2122
): InterceptorResult {
2223
// Let the agent know that we are connecting to this hostname
2324
// This is to build a list of all hostnames that the application is connecting to
2425
if (typeof port === "number" && port > 0) {
25-
agent.onConnectHostname(hostname, port);
26+
agent.onConnectHostname(url, port);
27+
agent.onConnectHTTP(url, port, method);
2628
}
2729
const context = getContext();
2830

@@ -31,7 +33,7 @@ export class Fetch implements Wrapper {
3133
}
3234

3335
return checkContextForSSRF({
34-
hostname: hostname,
36+
hostname: url.hostname,
3537
operation: "fetch",
3638
context: context,
3739
port: port,
@@ -40,15 +42,22 @@ export class Fetch implements Wrapper {
4042

4143
inspectFetch(args: unknown[], agent: Agent): InterceptorResult {
4244
if (args.length > 0) {
45+
// Extract method from options or Request object
46+
let method = "GET";
47+
if (args[0] instanceof Request) {
48+
method = args[0].method.toUpperCase();
49+
} else if (args.length > 1 && args[1] && typeof args[1] === "object") {
50+
const options = args[1] as { method?: string };
51+
if (options.method) {
52+
method = options.method.toUpperCase();
53+
}
54+
}
55+
4356
// URL string
4457
if (typeof args[0] === "string" && args[0].length > 0) {
4558
const url = tryParseURL(args[0]);
4659
if (url) {
47-
const attack = this.inspectHostname(
48-
agent,
49-
url.hostname,
50-
getPortFromURL(url)
51-
);
60+
const attack = this.inspectHostname(agent, url, getPortFromURL(url), method);
5261
if (attack) {
5362
return attack;
5463
}
@@ -62,11 +71,7 @@ export class Fetch implements Wrapper {
6271
if (Array.isArray(args[0])) {
6372
const url = tryParseURL(args[0].toString());
6473
if (url) {
65-
const attack = this.inspectHostname(
66-
agent,
67-
url.hostname,
68-
getPortFromURL(url)
69-
);
74+
const attack = this.inspectHostname(agent, url, getPortFromURL(url), method);
7075
if (attack) {
7176
return attack;
7277
}
@@ -77,8 +82,9 @@ export class Fetch implements Wrapper {
7782
if (args[0] instanceof URL && args[0].hostname.length > 0) {
7883
const attack = this.inspectHostname(
7984
agent,
80-
args[0].hostname,
81-
getPortFromURL(args[0])
85+
args[0],
86+
getPortFromURL(args[0]),
87+
method
8288
);
8389
if (attack) {
8490
return attack;
@@ -89,11 +95,7 @@ export class Fetch implements Wrapper {
8995
if (args[0] instanceof Request) {
9096
const url = tryParseURL(args[0].url);
9197
if (url) {
92-
const attack = this.inspectHostname(
93-
agent,
94-
url.hostname,
95-
getPortFromURL(url)
96-
);
98+
const attack = this.inspectHostname(agent, url, getPortFromURL(url), method);
9799
if (attack) {
98100
return attack;
99101
}

library/sinks/HTTPRequest.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ export class HTTPRequest implements Wrapper {
1919
agent: Agent,
2020
url: URL,
2121
port: number | undefined,
22-
module: "http" | "https"
22+
module: "http" | "https",
23+
method: string
2324
): InterceptorResult {
2425
// Let the agent know that we are connecting to this hostname
2526
// This is to build a list of all hostnames that the application is connecting to
2627
if (typeof port === "number" && port > 0) {
27-
agent.onConnectHostname(url.hostname, port);
28+
agent.onConnectHostname(url, port);
29+
agent.onConnectHTTP(url, port, method);
2830
}
2931
const context = getContext();
3032

@@ -74,11 +76,21 @@ export class HTTPRequest implements Wrapper {
7476
}
7577

7678
if (url.hostname.length > 0) {
79+
// Extract method from options object
80+
let method = "GET";
81+
const optionObj = args.find((arg): arg is RequestOptions =>
82+
isOptionsObject(arg)
83+
);
84+
if (optionObj && optionObj.method) {
85+
method = optionObj.method.toUpperCase();
86+
}
87+
7788
const attack = this.inspectHostname(
7889
agent,
7990
url,
8091
getPortFromURL(url),
81-
module
92+
module,
93+
method
8294
);
8395
if (attack) {
8496
return attack;

library/sinks/Undici.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@ const methods = [
2626
export class Undici implements Wrapper {
2727
private inspectHostname(
2828
agent: Agent,
29-
hostname: string,
29+
url: URL,
3030
port: number | undefined,
3131
method: string
3232
): InterceptorResult {
3333
// Let the agent know that we are connecting to this hostname
3434
// This is to build a list of all hostnames that the application is connecting to
3535
if (typeof port === "number" && port > 0) {
36-
agent.onConnectHostname(hostname, port);
36+
agent.onConnectHostname(url, port);
3737
}
3838
const context = getContext();
3939

@@ -42,7 +42,7 @@ export class Undici implements Wrapper {
4242
}
4343

4444
return checkContextForSSRF({
45-
hostname: hostname,
45+
hostname: url.hostname,
4646
operation: `undici.${method}`,
4747
context,
4848
port,

0 commit comments

Comments
 (0)