Skip to content

Commit 7c3beb4

Browse files
committed
Add blocking to Fetch, http and Undici
1 parent 662bc44 commit 7c3beb4

File tree

9 files changed

+208
-23
lines changed

9 files changed

+208
-23
lines changed

library/agent/Agent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -554,8 +554,8 @@ export class Agent {
554554
}
555555
}
556556

557-
onConnectHostname(hostname: string, port: number) {
558-
this.hostnames.add(hostname, port);
557+
onConnectHostname(hostname: string, port: number, blocked = false) {
558+
this.hostnames.add(hostname, port, blocked);
559559
}
560560

561561
onRouteExecute(context: Context) {

library/agent/Attack.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ export type Kind =
44
| "shell_injection"
55
| "path_traversal"
66
| "ssrf"
7-
| "code_injection";
7+
| "code_injection"
8+
| "blocked_outgoing_request";
89

910
export function attackKindHumanName(kind: Kind) {
1011
switch (kind) {
@@ -20,5 +21,7 @@ export function attackKindHumanName(kind: Kind) {
2021
return "a server-side request forgery";
2122
case "code_injection":
2223
return "a JavaScript injection";
24+
case "blocked_outgoing_request":
25+
return "an outgoing request";
2326
}
2427
}

library/agent/hooks/onInspectionInterceptorResult.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable max-lines-per-function */
12
import { resolve } from "path";
23
import { cleanupStackTrace } from "../../helpers/cleanupStackTrace";
34
import { escapeHTML } from "../../helpers/escapeHTML";
@@ -39,6 +40,14 @@ export function onInspectionInterceptorResult(
3940
context.remoteAddress &&
4041
agent.getConfig().isBypassedIP(context.remoteAddress);
4142

43+
if (result && !isBypassedIP && result.kind === "blocked_outgoing_request") {
44+
throw cleanError(
45+
new Error(
46+
`Zen has blocked ${attackKindHumanName(result.kind)}: ${result.operation}(...) to ${escapeHTML(result.payload as string)}`
47+
)
48+
);
49+
}
50+
4251
if (result && context && !isBypassedIP) {
4352
// Flag request as having an attack detected
4453
updateContext(context, "attackDetected", true);

library/sinks/Fetch.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,5 +427,48 @@ t.test(
427427
}
428428
}
429429
);
430+
431+
agent.getHostnames().clear();
432+
agent.getConfig().updateDomains([
433+
{ hostname: "aikido.dev", mode: "block" },
434+
{ hostname: "app.aikido.dev", mode: "allow" },
435+
]);
436+
437+
const blockedError1 = await t.rejects(() =>
438+
fetch("https://aikido.dev/block")
439+
);
440+
t.ok(blockedError1 instanceof Error);
441+
if (blockedError1 instanceof Error) {
442+
t.same(
443+
blockedError1.message,
444+
"Zen has blocked an outgoing request: fetch(...) to https://aikido.dev/block"
445+
);
446+
}
447+
448+
await fetch("https://app.aikido.dev");
449+
450+
t.same(agent.getHostnames().asArray(), [
451+
{ hostname: "aikido.dev", port: 443, hits: 1, blockedHits: 1 },
452+
{ hostname: "app.aikido.dev", port: 443, hits: 1, blockedHits: 0 },
453+
]);
454+
455+
agent.getConfig().setBlockNewOutgoingRequests(true);
456+
457+
const blockedError2 = await t.rejects(() => fetch("https://example.com"));
458+
t.ok(blockedError2 instanceof Error);
459+
if (blockedError2 instanceof Error) {
460+
t.same(
461+
blockedError2.message,
462+
"Zen has blocked an outgoing request: fetch(...) to https://example.com/"
463+
);
464+
}
465+
466+
await fetch("https://app.aikido.dev");
467+
468+
t.same(agent.getHostnames().asArray(), [
469+
{ hostname: "aikido.dev", port: 443, hits: 1, blockedHits: 1 },
470+
{ hostname: "app.aikido.dev", port: 443, hits: 2, blockedHits: 0 },
471+
{ hostname: "example.com", port: 443, hits: 1, blockedHits: 1 },
472+
]);
430473
}
431474
);

library/sinks/Fetch.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable max-lines-per-function */
21
import { lookup } from "dns";
32
import { Agent } from "../agent/Agent";
43
import { getContext } from "../agent/Context";
@@ -16,13 +15,28 @@ export class Fetch implements Wrapper {
1615

1716
private inspectHostname(
1817
agent: Agent,
19-
hostname: string,
18+
url: URL,
2019
port: number | undefined
2120
): InterceptorResult {
21+
if (agent.getConfig().shouldBlockOutgoingRequest(url.hostname)) {
22+
if (typeof port === "number" && port > 0) {
23+
agent.onConnectHostname(url.hostname, port, true);
24+
}
25+
26+
return {
27+
operation: "fetch",
28+
kind: "blocked_outgoing_request",
29+
source: "url",
30+
pathsToPayload: [],
31+
metadata: {},
32+
payload: url.href,
33+
};
34+
}
35+
2236
// Let the agent know that we are connecting to this hostname
2337
// This is to build a list of all hostnames that the application is connecting to
2438
if (typeof port === "number" && port > 0) {
25-
agent.onConnectHostname(hostname, port);
39+
agent.onConnectHostname(url.hostname, port);
2640
}
2741
const context = getContext();
2842

@@ -31,7 +45,7 @@ export class Fetch implements Wrapper {
3145
}
3246

3347
return checkContextForSSRF({
34-
hostname: hostname,
48+
hostname: url.hostname,
3549
operation: "fetch",
3650
context: context,
3751
port: port,
@@ -44,11 +58,7 @@ export class Fetch implements Wrapper {
4458
if (typeof args[0] === "string" && args[0].length > 0) {
4559
const url = tryParseURL(args[0]);
4660
if (url) {
47-
const attack = this.inspectHostname(
48-
agent,
49-
url.hostname,
50-
getPortFromURL(url)
51-
);
61+
const attack = this.inspectHostname(agent, url, getPortFromURL(url));
5262
if (attack) {
5363
return attack;
5464
}
@@ -62,11 +72,7 @@ export class Fetch implements Wrapper {
6272
if (Array.isArray(args[0])) {
6373
const url = tryParseURL(args[0].toString());
6474
if (url) {
65-
const attack = this.inspectHostname(
66-
agent,
67-
url.hostname,
68-
getPortFromURL(url)
69-
);
75+
const attack = this.inspectHostname(agent, url, getPortFromURL(url));
7076
if (attack) {
7177
return attack;
7278
}
@@ -77,7 +83,7 @@ export class Fetch implements Wrapper {
7783
if (args[0] instanceof URL && args[0].hostname.length > 0) {
7884
const attack = this.inspectHostname(
7985
agent,
80-
args[0].hostname,
86+
args[0],
8187
getPortFromURL(args[0])
8288
);
8389
if (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));
9799
if (attack) {
98100
return attack;
99101
}

library/sinks/HTTPRequest.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,49 @@ t.test("it works", (t) => {
301301
}
302302
});
303303

304+
agent.getHostnames().clear();
305+
agent.getConfig().updateDomains([
306+
{ hostname: "aikido.dev", mode: "block" },
307+
{ hostname: "app.aikido.dev", mode: "allow" },
308+
]);
309+
310+
const blockedError1 = t.throws(() =>
311+
https.request("https://aikido.dev/block")
312+
);
313+
if (blockedError1 instanceof Error) {
314+
t.same(
315+
blockedError1.message,
316+
"Zen has blocked an outgoing request: https.request(...) to https://aikido.dev/block"
317+
);
318+
}
319+
320+
const notBlocked1 = https.request("https://app.aikido.dev");
321+
notBlocked1.end();
322+
323+
t.same(agent.getHostnames().asArray(), [
324+
{ hostname: "aikido.dev", port: 443, hits: 1, blockedHits: 1 },
325+
{ hostname: "app.aikido.dev", port: 443, hits: 1, blockedHits: 0 },
326+
]);
327+
328+
agent.getConfig().setBlockNewOutgoingRequests(true);
329+
330+
const blockedError2 = t.throws(() => https.request("https://example.com"));
331+
if (blockedError2 instanceof Error) {
332+
t.same(
333+
blockedError2.message,
334+
"Zen has blocked an outgoing request: https.request(...) to https://example.com/"
335+
);
336+
}
337+
338+
const notBlocked2 = https.request("https://app.aikido.dev");
339+
notBlocked2.end();
340+
341+
t.same(agent.getHostnames().asArray(), [
342+
{ hostname: "aikido.dev", port: 443, hits: 1, blockedHits: 1 },
343+
{ hostname: "app.aikido.dev", port: 443, hits: 2, blockedHits: 0 },
344+
{ hostname: "example.com", port: 443, hits: 1, blockedHits: 1 },
345+
]);
346+
304347
setTimeout(() => {
305348
t.end();
306349
}, 3000);

library/sinks/HTTPRequest.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ export class HTTPRequest implements Wrapper {
2121
port: number | undefined,
2222
module: "http" | "https"
2323
): InterceptorResult {
24+
if (agent.getConfig().shouldBlockOutgoingRequest(url.hostname)) {
25+
if (typeof port === "number" && port > 0) {
26+
agent.onConnectHostname(url.hostname, port, true);
27+
}
28+
29+
return {
30+
operation: `${module}.request`,
31+
kind: "blocked_outgoing_request",
32+
source: "url",
33+
pathsToPayload: [],
34+
metadata: {},
35+
payload: url.href,
36+
};
37+
}
38+
2439
// Let the agent know that we are connecting to this hostname
2540
// This is to build a list of all hostnames that the application is connecting to
2641
if (typeof port === "number" && port > 0) {

library/sinks/Undici.tests.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,61 @@ export function createUndiciTests(undiciPkgName: string, port: number) {
418418
t.same(logger.getMessages(), [
419419
"undici.setGlobalDispatcher(..) was called, we can't guarantee protection!",
420420
]);
421+
422+
agent.getHostnames().clear();
423+
agent.getConfig().updateDomains([
424+
{ hostname: "aikido.dev", mode: "block" },
425+
{ hostname: "ssrf-redirects.testssandbox.com", mode: "allow" },
426+
]);
427+
428+
const blockedError1 = await t.rejects(() =>
429+
request("https://aikido.dev/block")
430+
);
431+
t.ok(blockedError1 instanceof Error);
432+
if (blockedError1 instanceof Error) {
433+
t.same(
434+
blockedError1.message,
435+
"Zen has blocked an outgoing request: undici.request(...) to aikido.dev"
436+
);
437+
}
438+
439+
await request("https://ssrf-redirects.testssandbox.com");
440+
441+
t.same(agent.getHostnames().asArray(), [
442+
{ hostname: "aikido.dev", port: 443, hits: 1, blockedHits: 1 },
443+
{
444+
hostname: "ssrf-redirects.testssandbox.com",
445+
port: 443,
446+
hits: 1,
447+
blockedHits: 0,
448+
},
449+
]);
450+
451+
agent.getConfig().setBlockNewOutgoingRequests(true);
452+
453+
const blockedError2 = await t.rejects(() =>
454+
request("https://example.com")
455+
);
456+
t.ok(blockedError2 instanceof Error);
457+
if (blockedError2 instanceof Error) {
458+
t.same(
459+
blockedError2.message,
460+
"Zen has blocked an outgoing request: undici.request(...) to example.com"
461+
);
462+
}
463+
464+
await request("https://ssrf-redirects.testssandbox.com");
465+
466+
t.same(agent.getHostnames().asArray(), [
467+
{ hostname: "aikido.dev", port: 443, hits: 1, blockedHits: 1 },
468+
{
469+
hostname: "ssrf-redirects.testssandbox.com",
470+
port: 443,
471+
hits: 2,
472+
blockedHits: 0,
473+
},
474+
{ hostname: "example.com", port: 443, hits: 1, blockedHits: 1 },
475+
]);
421476
}
422477
);
423478

library/sinks/Undici.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,21 @@ export class Undici implements Wrapper {
2929
port: number | undefined,
3030
method: string
3131
): InterceptorResult {
32+
if (agent.getConfig().shouldBlockOutgoingRequest(hostname)) {
33+
if (typeof port === "number" && port > 0) {
34+
agent.onConnectHostname(hostname, port, true);
35+
}
36+
37+
return {
38+
operation: `undici.${method}`,
39+
kind: "blocked_outgoing_request",
40+
source: "url",
41+
pathsToPayload: [],
42+
metadata: {},
43+
payload: hostname,
44+
};
45+
}
46+
3247
// Let the agent know that we are connecting to this hostname
3348
// This is to build a list of all hostnames that the application is connecting to
3449
if (typeof port === "number" && port > 0) {

0 commit comments

Comments
 (0)