Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable SSRF redirect protection and add breaking test for AWS SDK v3 #346

Open
wants to merge 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c3fda7a
Enable SSRF redirect protection and add breaking test for AWS SDK v3
hansott Aug 28, 2024
284e8c6
Wrap response handler if there's one, don't consume body
hansott Aug 28, 2024
67726c1
Fix types
hansott Aug 28, 2024
e8f1548
Delete library/sinks/AwsSDKVersion3.ts
hansott Aug 28, 2024
bfba91e
Set timeouts
hansott Aug 28, 2024
5a1c464
Merge branch 'patch-ssrf-enable' of github.com:AikidoSec/node-RASP in…
hansott Aug 28, 2024
e2dc0a4
Don't fail other unit test jobs if one fails
hansott Aug 29, 2024
4c6d7d4
Wrap all methods to add event listeners
hansott Aug 29, 2024
a76a132
Extract function
hansott Aug 29, 2024
21609ee
Test whether redirect isn't added twice
hansott Aug 29, 2024
64cfe5f
Split in separate test (because it hangs on 19+, even without firewall)
hansott Aug 29, 2024
fb074f6
Consume body in test
hansott Aug 29, 2024
9d8ed08
Update comment
hansott Aug 29, 2024
2cd72d0
Combine imports
hansott Aug 29, 2024
db5fa22
Rewrite for readability
hansott Aug 29, 2024
8eac1b5
Add comments to wrapping
hansott Aug 30, 2024
6bde6ab
Fix additional dependencies
timokoessler Sep 2, 2024
beea69d
Add direct test with event listener
timokoessler Sep 2, 2024
1557146
Merge branch 'beta' of github.com:AikidoSec/node-RASP into patch-ssrf…
hansott Nov 20, 2024
a0c2dc0
Fixes after merging
hansott Nov 20, 2024
f8f9263
Add infinite loop test
hansott Nov 20, 2024
bae368d
Protect against infinite loops in `getRedirectOrigin`
hansott Nov 20, 2024
0201229
Add more tests for `getRedirectOrigin`
hansott Nov 20, 2024
cce03ea
Remove redundant check
hansott Nov 20, 2024
dc1c3f7
Simplify algo
hansott Nov 20, 2024
bc88f6c
Use agent helper function
hansott Nov 21, 2024
d197d60
Fix test
hansott Nov 21, 2024
51c055a
Merge branch 'main' of github.com:AikidoSec/node-RASP into patch-ssrf…
hansott Dec 18, 2024
64d57f4
Improved comment
hansott Dec 18, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,647 changes: 1,656 additions & 2,991 deletions library/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"devDependencies": {
"@clickhouse/client": "^1.7.0",
"@fastify/cookie": "^10.0.0",
"@aws-sdk/client-s3": "^3.637.0",
"@aws-sdk/credential-providers": "^3.637.0",
"@google-cloud/functions-framework": "^3.3.0",
"@google-cloud/pubsub": "^4.3.3",
"@graphql-tools/executor": "^1.3.2",
Expand Down
40 changes: 40 additions & 0 deletions library/sinks/AwsSDKVersion3.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { CreateBucketCommand, ListBucketsCommand } from "@aws-sdk/client-s3";
import { fromEnv } from "@aws-sdk/credential-providers";
import * as t from "tap";
import { createTestAgent } from "../helpers/createTestAgent";
import { HTTPRequest } from "./HTTPRequest";

t.test("it works", async (t) => {
const agent = createTestAgent();

agent.start([new HTTPRequest()]);

const { S3Client } =
require("@aws-sdk/client-s3") as typeof import("@aws-sdk/client-s3");

process.env.AWS_ACCESS_KEY_ID = "test";
process.env.AWS_SECRET_ACCESS_KEY = "test";

const s3 = new S3Client({
region: "us-east-1",
endpoint: "http://localhost:9090",
credentials: fromEnv(),
forcePathStyle: true,
});

const name = "bucket";

try {
await s3.send(new CreateBucketCommand({ Bucket: name }));
} catch (err: any) {
if (err.Code !== "BucketAlreadyOwnedByYou") {
throw err;
}
}

const buckets = await s3.send(new ListBucketsCommand({}));
t.same(
buckets.Buckets?.map((bucket) => bucket.Name),
[name]
);
});
3 changes: 1 addition & 2 deletions library/sinks/HTTPRequest.axios.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,10 @@ const context: Context = {

const redirectTestUrl = "http://ssrf-redirects.testssandbox.com";

t.test("it works", { skip: "SSRF redirect check disabled atm" }, async (t) => {
t.test("it works", async (t) => {
const agent = createTestAgent({
token: new Token("123"),
});

agent.start([new HTTPRequest()]);

t.same(agent.getHostnames().asArray(), []);
Expand Down
66 changes: 66 additions & 0 deletions library/sinks/HTTPRequest.context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/* eslint-disable prefer-rest-params */
import * as t from "tap";
import { Agent } from "../agent/Agent";
import { ReportingAPIForTesting } from "../agent/api/ReportingAPIForTesting";
import { Token } from "../agent/api/Token";
import { Context, getContext, runWithContext } from "../agent/Context";
import { LoggerNoop } from "../agent/logger/LoggerNoop";
import { createTestAgent } from "../helpers/createTestAgent";
import { HTTPRequest } from "./HTTPRequest";

const source =
"http://firewallssrfredirects-env-2.eba-7ifve22q.eu-north-1.elasticbeanstalk.com/ssrf-test";
const destination = "http://127.0.0.1/test";

const context: Context = {
remoteAddress: "::1",
method: "POST",
url: "http://localhost:4000",
query: {},
headers: {},
body: {
image: source,
},
cookies: {},
routeParams: {},
source: "express",
route: "/posts/:id",
};

t.test("it wraps ", (t) => {
const agent = createTestAgent({
token: new Token("123"),
});
agent.start([new HTTPRequest()]);

const http = require("http") as typeof import("http");

runWithContext(context, () => {
const request = http.request(source, (res) => {
res.on("data", () => {});
});

request.on("response", (res) => {
t.same(
getContext()!.outgoingRequestRedirects!.map((r) => ({
source: r.source.toString(),
destination: r.destination.toString(),
})),
[
{
source: source,
destination: destination,
},
]
);
t.end();
});

request.on("error", (e) => {
t.fail(e);
t.end();
});

request.end();
});
});
14 changes: 11 additions & 3 deletions library/sinks/HTTPRequest.followRedirects.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { IncomingMessage } from "http";
import * as t from "tap";
import { Token } from "../agent/api/Token";
import { Context, runWithContext } from "../agent/Context";
Expand All @@ -22,6 +23,14 @@ const context: Context = {
let server: import("http").Server;
const port = 3000;

function consumeBody(res: IncomingMessage) {
// We need to consume the body
// From Node.19+ this would otherwise hang the test
res.on("readable", () => {
while (res.read() !== null) {}
});
}

t.before(async () => {
const { createServer } = require("http") as typeof import("http");

Expand All @@ -39,7 +48,7 @@ t.before(async () => {

const redirectTestUrl = "http://ssrf-redirects.testssandbox.com";

t.test("it works", { skip: "SSRF redirect check disabled atm" }, (t) => {
t.test("it works", (t) => {
const agent = createTestAgent({
token: new Token("123"),
});
Expand Down Expand Up @@ -120,8 +129,7 @@ t.test("it works", { skip: "SSRF redirect check disabled atm" }, (t) => {
},
() => {
const response = http.request(`http://[::]:${port}`, (res) => {
// consume body
while (res.read()) {}
consumeBody(res);

t.same(res.statusCode, 200);
});
Expand Down
19 changes: 12 additions & 7 deletions library/sinks/HTTPRequest.localhost.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
/* eslint-disable prefer-rest-params */
import * as t from "tap";
import { Agent } from "../agent/Agent";
import { createServer, IncomingMessage, Server } from "http";
import { ReportingAPIForTesting } from "../agent/api/ReportingAPIForTesting";
import { Token } from "../agent/api/Token";
import { Context, runWithContext } from "../agent/Context";
import { LoggerNoop } from "../agent/logger/LoggerNoop";
import { createTestAgent } from "../helpers/createTestAgent";
import { HTTPRequest } from "./HTTPRequest";

Expand Down Expand Up @@ -52,6 +49,14 @@ function createContext({
};
}

function consumeBody(res: IncomingMessage) {
// We need to consume the body
// From Node.19+ this would otherwise hang the test
res.on("readable", () => {
while (res.read() !== null) {}
});
}

const agent = createTestAgent({
token: new Token("123"),
});
Expand Down Expand Up @@ -92,8 +97,8 @@ t.test("it does not block request to localhost with same port", (t) => {
// The server should respond with a 200
// Because we'll allow requests to localhost if it's the same port
t.same(response.statusCode, 200);
response.on("data", () => {});
response.on("end", () => {});

consumeBody(response);
});
request.end();
}
Expand Down Expand Up @@ -129,8 +134,8 @@ t.test("it blocks requests to other ports", (t) => {
request.on("response", (response: IncomingMessage) => {
// This should not be called
t.fail();
response.on("data", () => {});
response.on("end", () => {});

consumeBody(response);
});
request.end();
} catch (error) {
Expand Down
119 changes: 72 additions & 47 deletions library/sinks/HTTPRequest.needle.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
import * as t from "tap";
import { Token } from "../agent/api/Token";
import { Context, runWithContext } from "../agent/Context";
import { getMajorNodeVersion } from "../helpers/getNodeVersion";
import { HTTPRequest } from "./HTTPRequest";
import { createTestAgent } from "../helpers/createTestAgent";

const context: Context = {
remoteAddress: "::1",
method: "POST",
url: "http://localhost:4000",
query: {},
headers: {},
body: {
image: "http://localhost:5000/api/internal",
},
cookies: {},
routeParams: {},
source: "express",
route: "/posts/:id",
};

const redirectTestUrl = "http://ssrf-redirects.testssandbox.com";
function createContext(obj = {}): Context {
return {
...{
remoteAddress: "::1",
method: "POST",
url: "http://localhost:4000",
query: {},
headers: {},
body: {
image: "http://localhost:5000/api/internal",
},
cookies: {},
routeParams: {},
source: "express",
route: "/posts/:id",
},
...obj,
};
}

t.test("it works", { skip: "SSRF redirect check disabled atm" }, async (t) => {
t.test("it works", async (t) => {
const agent = createTestAgent({
token: new Token("123"),
});
Expand All @@ -31,7 +35,7 @@ t.test("it works", { skip: "SSRF redirect check disabled atm" }, async (t) => {

const needle = require("needle") as typeof import("needle");

await runWithContext(context, async () => {
await runWithContext(createContext(), async () => {
await needle("get", "https://www.aikido.dev");
});

Expand All @@ -42,42 +46,63 @@ t.test("it works", { skip: "SSRF redirect check disabled atm" }, async (t) => {

const error = await t.rejects(
async () =>
await runWithContext(context, async () => {
await runWithContext(createContext(), async () => {
await needle("get", "http://localhost:5000/api/internal");
})
);

t.ok(error instanceof Error);
if (error instanceof Error) {
t.same(
error.message,
"Zen has blocked a server-side request forgery: http.request(...) originating from body.image"
);
}

await runWithContext(
{
...context,
...{ body: { image: `${redirectTestUrl}/ssrf-test-domain` } },
},
async () => {
await new Promise<void>((resolve) => {
needle.request(
"get",
`${redirectTestUrl}/ssrf-test-domain`,
{},
{
// eslint-disable-next-line camelcase
follow_max: 1,
},
(error, response) => {
t.ok(error instanceof Error);
t.match(
error?.message,
/Zen has blocked a server-side request forgery/
);
resolve();
}
);
});
}
);
});

const redirectTestUrl = "http://ssrf-redirects.testssandbox.com";

t.test(
"it detects SSRF attacks with redirects",
{
skip:
getMajorNodeVersion() >= 19 ? "This request hangs on Node.js 19+" : false,
},
async (t) => {
const agent = createTestAgent({
token: new Token("123"),
});
agent.start([new HTTPRequest()]);

const needle = require("needle") as typeof import("needle");

await runWithContext(
createContext({ body: { image: `${redirectTestUrl}/ssrf-test-domain` } }),
async () => {
await new Promise<void>((resolve) => {
needle.request(
"get",
`${redirectTestUrl}/ssrf-test-domain`,
{},
{
/* eslint-disable camelcase */
follow_max: 1,
open_timeout: 5000,
response_timeout: 5000,
read_timeout: 5000,
/* eslint-enable camelcase */
},
(error, response) => {
t.ok(error instanceof Error);
t.match(
error?.message,
/Zen has blocked a server-side request forgery/
);
resolve();
}
);
});
}
);
}
);
2 changes: 1 addition & 1 deletion library/sinks/HTTPRequest.nodeFetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const context: Context = {

const redirectTestUrl = "http://ssrf-redirects.testssandbox.com";

t.test("it works", { skip: "SSRF redirect check disabled atm" }, async (t) => {
t.test("it works", async (t) => {
const agent = createTestAgent({
token: new Token("123"),
});
Expand Down
Loading
Loading