Skip to content

Commit

Permalink
Merge pull request #394 from AikidoSec/patch-host-header
Browse files Browse the repository at this point in the history
Fix false positive for application doing a request to itself on localhost
  • Loading branch information
hansott authored Nov 12, 2024
2 parents 2fab0eb + 481e0b6 commit 59bb172
Show file tree
Hide file tree
Showing 11 changed files with 683 additions and 24 deletions.
2 changes: 1 addition & 1 deletion library/agent/applyHooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ t.test(
t.same(applyHooks(hooks, agent), {});

await runWithContext(context, async () => {
await fetch("https://aikido.dev");
await fetch("https://app.aikido.dev");
t.same(modifyCalled, true);

atob("aGVsbG8gd29ybGQ=");
Expand Down
185 changes: 185 additions & 0 deletions library/sinks/Fetch.localhost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/* eslint-disable prefer-rest-params */
import * as t from "tap";
import { Agent } from "../agent/Agent";
import { createServer, 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 { Fetch } from "./Fetch";

function createContext({
url,
hostHeader,
body,
additionalHeaders = {},
}: {
url: string;
hostHeader: string;
body: unknown;
additionalHeaders?: Record<string, string>;
}): Context {
return {
url: url,
method: "GET",
headers: {
host: hostHeader,
connection: "keep-alive",
"cache-control": "max-age=0",
"sec-ch-ua":
'"Google Chrome";v="129", "Not=A?Brand";v="8", "Chromium";v="129"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"upgrade-insecure-requests": "1",
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"sec-fetch-site": "none",
"sec-fetch-mode": "navigate",
"sec-fetch-user": "?1",
"sec-fetch-dest": "document",
"accept-encoding": "gzip, deflate, br, zstd",
"accept-language": "nl,en;q=0.9,en-US;q=0.8",
...additionalHeaders,
},
route: "/",
query: {},
source: "express",
routeParams: {},
cookies: {},
remoteAddress: "127.0.0.1",
subdomains: [],
body: body,
};
}

const agent = new Agent(
true,
new LoggerNoop(),
new ReportingAPIForTesting(),
new Token("123"),
undefined
);

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

const port = 1341;
const serverUrl = `http://localhost:${port}`;
const hostHeader = `localhost:${port}`;

let server: Server;
t.before(async () => {
server = createServer((_, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello World\n");
});

return new Promise<void>((resolve) => {
server.listen(port, resolve);
server.unref();
});
});

t.test(
"it does not block request to localhost with same port",
{ skip: !global.fetch ? "fetch is not available" : false },
async (t) => {
await runWithContext(
createContext({
url: serverUrl,
hostHeader: hostHeader,
body: {},
}),
async () => {
// Server doing a request to itself
const response = await fetch(`${serverUrl}/favicon.ico`);
// The server should respond with a 200
t.same(response.status, 200);
}
);
}
);

t.test(
"it does not block request to localhost with same port using the origin header",
{ skip: !global.fetch ? "fetch is not available" : false },
async (t) => {
await runWithContext(
createContext({
url: serverUrl,
hostHeader: "",
body: {},
additionalHeaders: {
origin: serverUrl,
},
}),
async () => {
// Server doing a request to itself
const response = await fetch(`${serverUrl}/favicon.ico`);
// The server should respond with a 200
t.same(response.status, 200);
}
);
}
);

t.test(
"it does not block request to localhost with same port using the referer header",
{ skip: !global.fetch ? "fetch is not available" : false },
async (t) => {
await runWithContext(
createContext({
url: serverUrl,
hostHeader: "",
body: {},
additionalHeaders: {
referer: serverUrl,
},
}),
async () => {
// Server doing a request to itself
const response = await fetch(`${serverUrl}/favicon.ico`);
// The server should respond with a 200
t.same(response.status, 200);
}
);
}
);

t.test(
"it blocks requests to other ports",
{ skip: !global.fetch ? "fetch is not available" : false },
async (t) => {
const error = await t.rejects(async () => {
await runWithContext(
createContext({
url: `http://localhost:${port + 1}`,
hostHeader: `localhost:${port + 1}`,
body: {
url: `${serverUrl}/favicon.ico`,
},
}),
async () => {
// Server doing a request to localhost but with a different port
// This should be blocked
await fetch(`${serverUrl}/favicon.ico`);
// This should not be called
t.fail();
}
);
});

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

t.after(() => {
server.close();
});
8 changes: 4 additions & 4 deletions library/sinks/Fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,17 @@ t.test(

t.same(agent.getHostnames().asArray(), []);

await fetch("http://aikido.dev");
await fetch("http://app.aikido.dev");

t.same(agent.getHostnames().asArray(), [
{ hostname: "aikido.dev", port: 80 },
{ hostname: "app.aikido.dev", port: 80 },
]);
agent.getHostnames().clear();

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

t.same(agent.getHostnames().asArray(), [
{ hostname: "aikido.dev", port: 443 },
{ hostname: "app.aikido.dev", port: 443 },
]);
agent.getHostnames().clear();

Expand Down
158 changes: 158 additions & 0 deletions library/sinks/HTTPRequest.localhost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/* 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 { HTTPRequest } from "./HTTPRequest";

function createContext({
url,
hostHeader,
body,
}: {
url: string;
hostHeader: string;
body: unknown;
}): Context {
return {
url: url,
method: "GET",
headers: {
host: hostHeader,
connection: "keep-alive",
"cache-control": "max-age=0",
"sec-ch-ua":
'"Google Chrome";v="129", "Not=A?Brand";v="8", "Chromium";v="129"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"upgrade-insecure-requests": "1",
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"sec-fetch-site": "none",
"sec-fetch-mode": "navigate",
"sec-fetch-user": "?1",
"sec-fetch-dest": "document",
"accept-encoding": "gzip, deflate, br, zstd",
"accept-language": "nl,en;q=0.9,en-US;q=0.8",
},
route: "/",
query: {},
source: "express",
routeParams: {},
cookies: {},
remoteAddress: "127.0.0.1",
subdomains: [],
body: body,
};
}

const agent = new Agent(
true,
new LoggerNoop(),
new ReportingAPIForTesting(),
new Token("123"),
undefined
);

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

const port = 1343;
const serverUrl = `http://localhost:${port}`;
const hostHeader = `localhost:${port}`;

let server: Server;
t.before(async () => {
server = createServer((_, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello World\n");
});

return new Promise<void>((resolve) => {
server.listen(port, resolve);
server.unref();
});
});

t.test("it does not block request to localhost with same port", (t) => {
const http = require("http");

runWithContext(
createContext({
url: serverUrl,
hostHeader: hostHeader,
body: {},
}),
() => {
// Server doing a request to itself
// Let's simulate a request to a favicon
const request = http.request(`${serverUrl}/favicon.ico`);
request.on("response", (response: IncomingMessage) => {
// 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", () => {});
});
request.end();
}
);

const errors: Error[] = [];
process.on("uncaughtException", (error) => {
errors.push(error);
});

setTimeout(() => {
t.same(errors, []);
t.end();
}, 1000);
});

t.test("it blocks requests to other ports", (t) => {
const http = require("http");

runWithContext(
createContext({
url: `http://localhost:${port + 1}`,
hostHeader: `localhost:${port + 1}`,
body: {
url: `${serverUrl}/favicon.ico`,
},
}),
() => {
try {
// Server doing a request to localhost but with a different port
// This should be blocked
const request = http.request(`${serverUrl}/favicon.ico`);
request.on("response", (response: IncomingMessage) => {
// This should not be called
t.fail();
response.on("data", () => {});
response.on("end", () => {});
});
request.end();
} catch (error) {
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.url"
);
}
}
}
);

setTimeout(() => {
t.end();
}, 1000);
});

t.after(() => {
server.close();
});
4 changes: 1 addition & 3 deletions library/sinks/Path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,7 @@ t.test("it works", async (t) => {
const error = t.throws(() => join("/etc/", "test.txt"));
t.same(
error instanceof Error ? error.message : null,
getMajorNodeVersion() <= 22
? "Zen has blocked a path traversal attack: path.normalize(...) originating from body.file.matches"
: "Zen has blocked a path traversal attack: path.join(...) originating from body.file.matches"
"Zen has blocked a path traversal attack: path.normalize(...) originating from body.file.matches"
);

const error2 = t.throws(() => resolve("/etc/some_directory", "test.txt"));
Expand Down
Loading

0 comments on commit 59bb172

Please sign in to comment.