Skip to content

Commit 101e22d

Browse files
authored
Merge pull request #158 from AikidoSec/AIK-2590
Protect against SSRF attacks
2 parents c6c7304 + 8df835e commit 101e22d

27 files changed

+1990
-89
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Firewall autonomously protects your Node.js applications against:
2222
* 🛡️ [Command injection attacks](https://owasp.org/www-community/attacks/Command_Injection)
2323
* 🛡️ [Prototype pollution](./docs/prototype-pollution.md)
2424
* 🛡️ [Path traversal attacks](https://owasp.org/www-community/attacks/Path_Traversal)
25+
* 🛡️ [Server-side request forgery (SSRF)](./docs/ssrf.md)
2526
* 🚀 More to come (see the [public roadmap](https://github.com/orgs/AikidoSec/projects/2/views/1))!
2627

2728
Firewall operates autonomously on the same server as your Node.js app to:

docs/ssrf.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Server-side request forgery (SSRF)
2+
3+
Aikido firewall for Node.js 16+ secures your app against server-side request forgery (SSRF) attacks. SSRF vulnerabilities allow attackers to send crafted requests to internal services, bypassing firewalls and security controls. Runtime blocks SSRF attacks by intercepting and validating requests to internal services.
4+
5+
## Example
6+
7+
```
8+
GET https://your-app.com/files?url=http://localhost:3000/private
9+
```
10+
11+
```js
12+
const response = http.request(req.query.url);
13+
```
14+
15+
In this example, an attacker sends a request to `localhost:3000/private` from your server. Firewall can intercept the request and block it, preventing the attacker from accessing internal services.
16+
17+
```
18+
GET https://your-app.com/files?url=http://localtest.me:3000/private
19+
```
20+
21+
In this example, the attacker sends a request to `localtest.me:3000/private`, which resolves to `127.0.0.1`. Firewall can intercept the request and block it, preventing the attacker from accessing internal services.
22+
23+
We don't protect against stored SSRF attacks, where an attacker injects a malicious URL into your app's database. To prevent stored SSRF attacks, validate and sanitize user input before storing it in your database.
24+
25+
## Which built-in modules are protected?
26+
27+
Firewall protects against SSRF attacks in the following built-in modules:
28+
* `http`
29+
* `https`
30+
* `undici`
31+
* `globalThis.fetch` (Node.js 18+)

library/agent/Agent.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,10 @@ export class Agent {
425425
return this.routes;
426426
}
427427

428+
log(message: string) {
429+
this.logger.log(message);
430+
}
431+
428432
async flushStats(timeoutInMS: number) {
429433
this.statistics.forceCompress();
430434
await this.sendHeartbeat(timeoutInMS);

library/agent/Attack.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ export type Kind =
22
| "nosql_injection"
33
| "sql_injection"
44
| "shell_injection"
5-
| "path_traversal";
5+
| "path_traversal"
6+
| "ssrf";
67

78
export function attackKindHumanName(kind: Kind) {
89
switch (kind) {
@@ -14,5 +15,7 @@ export function attackKindHumanName(kind: Kind) {
1415
return "a shell injection";
1516
case "path_traversal":
1617
return "a path traversal attack";
18+
case "ssrf":
19+
return "a server-side request forgery";
1720
}
1821
}

library/agent/applyHooks.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,9 @@ t.test("it tries to wrap method that does not exist", async (t) => {
9494
});
9595

9696
t.same(logger.getMessages(), [
97-
"Failed to wrap method does_not_exist in module shell-quote",
98-
"Failed to wrap method another_method_that_does_not_exist in module shell-quote",
9997
"Failed to wrap method another_second_method_that_does_not_exist in module shell-quote",
98+
"Failed to wrap method another_method_that_does_not_exist in module shell-quote",
99+
"Failed to wrap method does_not_exist in module shell-quote",
100100
]);
101101
});
102102

library/agent/applyHooks.ts

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -92,17 +92,15 @@ export function applyHooks(hooks: Hooks, agent: Agent) {
9292
return;
9393
}
9494

95-
const interceptor = g.getMethodInterceptor();
96-
97-
if (!interceptor) {
98-
return;
99-
}
100-
101-
if (interceptor instanceof ModifyingArgumentsMethodInterceptor) {
102-
wrapWithArgumentModification(global, interceptor, "global", agent);
103-
} else {
104-
wrapWithoutArgumentModification(global, interceptor, "global", agent);
105-
}
95+
g.getMethodInterceptors()
96+
.reverse() // Reverse to make sure we wrap in the order they were added
97+
.forEach((interceptor) => {
98+
if (interceptor instanceof ModifyingArgumentsMethodInterceptor) {
99+
wrapWithArgumentModification(global, interceptor, name, agent);
100+
} else {
101+
wrapWithoutArgumentModification(global, interceptor, name, agent);
102+
}
103+
});
106104
});
107105

108106
return wrapped;
@@ -364,15 +362,18 @@ function wrapSubject(
364362
return;
365363
}
366364

367-
subject.getMethodInterceptors().forEach((method) => {
368-
if (method instanceof ModifyingArgumentsMethodInterceptor) {
369-
wrapWithArgumentModification(theSubject, method, module, agent);
370-
} else if (method instanceof MethodInterceptor) {
371-
wrapWithoutArgumentModification(theSubject, method, module, agent);
372-
} else if (method instanceof MethodResultInterceptor) {
373-
wrapWithResult(theSubject, method, module, agent);
374-
} else {
375-
wrapNewInstance(theSubject, method, module, agent);
376-
}
377-
});
365+
subject
366+
.getMethodInterceptors()
367+
.reverse() // Reverse to make sure we wrap in the order they were added
368+
.forEach((method) => {
369+
if (method instanceof ModifyingArgumentsMethodInterceptor) {
370+
wrapWithArgumentModification(theSubject, method, module, agent);
371+
} else if (method instanceof MethodInterceptor) {
372+
wrapWithoutArgumentModification(theSubject, method, module, agent);
373+
} else if (method instanceof MethodResultInterceptor) {
374+
wrapWithResult(theSubject, method, module, agent);
375+
} else {
376+
wrapNewInstance(theSubject, method, module, agent);
377+
}
378+
});
378379
}

library/agent/hooks/Global.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@ import {
55
} from "./ModifyingArgumentsInterceptor";
66

77
export class Global {
8-
private method:
9-
| MethodInterceptor
10-
| ModifyingArgumentsMethodInterceptor
11-
| undefined = undefined;
8+
private methods: (MethodInterceptor | ModifyingArgumentsMethodInterceptor)[] =
9+
[];
1210

1311
constructor(private readonly name: string) {
1412
if (!this.name) {
@@ -22,7 +20,8 @@ export class Global {
2220
* This is the preferred way to use when wrapping methods
2321
*/
2422
inspect(interceptor: Interceptor) {
25-
this.method = new MethodInterceptor(this.name, interceptor);
23+
const method = new MethodInterceptor(this.name, interceptor);
24+
this.methods.push(method);
2625

2726
return this;
2827
}
@@ -35,10 +34,11 @@ export class Global {
3534
* Don't use this unless you have to, it's better to use inspect
3635
*/
3736
modifyArguments(interceptor: ModifyingArgumentsInterceptor) {
38-
this.method = new ModifyingArgumentsMethodInterceptor(
37+
const method = new ModifyingArgumentsMethodInterceptor(
3938
this.name,
4039
interceptor
4140
);
41+
this.methods.push(method);
4242

4343
return this;
4444
}
@@ -47,7 +47,7 @@ export class Global {
4747
return this.name;
4848
}
4949

50-
getMethodInterceptor() {
51-
return this.method;
50+
getMethodInterceptors() {
51+
return this.methods;
5252
}
5353
}
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Logger } from "./Logger";
22

33
export class LoggerForTesting implements Logger {
4-
private readonly messages: string[] = [];
4+
private messages: string[] = [];
55

66
log(message: string) {
77
this.messages.push(message);
@@ -10,4 +10,8 @@ export class LoggerForTesting implements Logger {
1010
getMessages() {
1111
return this.messages;
1212
}
13+
14+
clear() {
15+
this.messages = [];
16+
}
1317
}

library/helpers/tryParseURL.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export function tryParseURL(url: string) {
1+
export function tryParseURL(url: string): URL | undefined {
22
try {
33
return new URL(url);
44
} catch {

library/sinks/Fetch.test.ts

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,49 @@
1+
/* eslint-disable prefer-rest-params */
12
import * as t from "tap";
23
import { Agent } from "../agent/Agent";
34
import { ReportingAPIForTesting } from "../agent/api/ReportingAPIForTesting";
4-
import { Token } from "../agent/api/Token";
5+
import { Context, runWithContext } from "../agent/Context";
56
import { LoggerNoop } from "../agent/logger/LoggerNoop";
7+
import { wrap } from "../helpers/wrap";
68
import { Fetch } from "./Fetch";
9+
import * as dns from "dns";
10+
11+
const calls: Record<string, number> = {};
12+
wrap(dns, "lookup", function lookup(original) {
13+
return function lookup() {
14+
const hostname = arguments[0];
15+
16+
if (!calls[hostname]) {
17+
calls[hostname] = 0;
18+
}
19+
20+
calls[hostname]++;
21+
22+
if (hostname === "thisdomainpointstointernalip.com") {
23+
return original.apply(this, [
24+
"localhost",
25+
...Array.from(arguments).slice(1),
26+
]);
27+
}
28+
29+
original.apply(this, arguments);
30+
};
31+
});
32+
33+
const context: Context = {
34+
remoteAddress: "::1",
35+
method: "POST",
36+
url: "http://localhost:4000",
37+
query: {},
38+
headers: {},
39+
body: {
40+
image: "http://localhost:4000/api/internal",
41+
},
42+
cookies: {},
43+
routeParams: {},
44+
source: "express",
45+
route: "/posts/:id",
46+
};
747

848
t.test(
949
"it works",
@@ -13,7 +53,7 @@ t.test(
1353
true,
1454
new LoggerNoop(),
1555
new ReportingAPIForTesting(),
16-
new Token("123"),
56+
undefined,
1757
undefined
1858
);
1959
agent.start([new Fetch()]);
@@ -39,5 +79,51 @@ t.test(
3979

4080
t.same(agent.getHostnames().asArray(), []);
4181
agent.getHostnames().clear();
82+
83+
await runWithContext(context, async () => {
84+
await fetch("https://google.com");
85+
const error = await t.rejects(() =>
86+
fetch("http://localhost:4000/api/internal")
87+
);
88+
if (error instanceof Error) {
89+
t.same(
90+
error.message,
91+
"Aikido firewall has blocked a server-side request forgery: fetch(...) originating from body.image"
92+
);
93+
}
94+
95+
const error2 = await t.rejects(() =>
96+
fetch(new URL("http://localhost:4000/api/internal"))
97+
);
98+
if (error2 instanceof Error) {
99+
t.same(
100+
error2.message,
101+
"Aikido firewall has blocked a server-side request forgery: fetch(...) originating from body.image"
102+
);
103+
}
104+
});
105+
106+
await runWithContext(
107+
{
108+
...context,
109+
...{ body: { image: "http://thisdomainpointstointernalip.com" } },
110+
},
111+
async () => {
112+
const error = await t.rejects(() =>
113+
fetch("http://thisdomainpointstointernalip.com")
114+
);
115+
if (error instanceof Error) {
116+
t.same(
117+
// @ts-expect-error Type is not defined
118+
error.cause.message,
119+
"Aikido firewall has blocked a server-side request forgery: fetch(...) originating from body.image"
120+
);
121+
}
122+
123+
// Ensure the lookup is only called once per hostname
124+
// Otherwise, it could be vulnerable to TOCTOU
125+
t.same(calls["thisdomainpointstointernalip.com"], 1);
126+
}
127+
);
42128
}
43129
);

0 commit comments

Comments
 (0)