Skip to content

Prevent undici SSRF attacks with redirects (2) #338

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

Draft
wants to merge 11 commits into
base: patch-ssrf-enable
Choose a base branch
from
8 changes: 8 additions & 0 deletions docs/ssrf.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,15 @@ We don't protect against stored SSRF attacks, where an attacker injects a malici
## Which built-in modules are protected?

Firewall protects against SSRF attacks in the following built-in modules:

* `http`
* `https`
* `undici`
* `globalThis.fetch` (Node.js 18+)

Modules that use `node:http` or `node:https` are also supported:

* `axios`
* `node-fetch`
* `follow-redirects`
* ...
3 changes: 2 additions & 1 deletion library/sinks/Fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ export class Fetch implements Wrapper {
globalThis[undiciGlobalDispatcherSymbol].dispatch = wrapDispatch(
// @ts-expect-error Type is not defined
globalThis[undiciGlobalDispatcherSymbol].dispatch,
agent
agent,
true
);
} catch (error) {
agent.log(
Expand Down
118 changes: 118 additions & 0 deletions library/sinks/Undici.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ import { wrap } from "../helpers/wrap";
import { getMajorNodeVersion } from "../helpers/getNodeVersion";
import { Undici } from "./Undici";

const redirectTestUrl = "http://ssrf-redirects.testssandbox.com";
const redirectTestUrl2 =
"http://firewallssrfredirects-env-2.eba-7ifve22q.eu-north-1.elasticbeanstalk.com";

const redirectUrl = {
ip: `${redirectTestUrl}/ssrf-test`, // Redirects to http://127.0.0.1/test
domain: `${redirectTestUrl}/ssrf-test-domain`, // Redirects to http://local.aikido.io/test
ipTwice: `${redirectTestUrl}/ssrf-test-twice`, // Redirects to /ssrf-test
domainTwice: `${redirectTestUrl}/ssrf-test-domain-twice`, // Redirects to /ssrf-test-domain
};

export function createUndiciTests(undiciPkgName: string, port: number) {
const calls: Record<string, number> = {};
wrap(dns, "lookup", function lookup(original) {
Expand Down Expand Up @@ -349,6 +360,113 @@ export function createUndiciTests(undiciPkgName: string, port: number) {
}
);

await runWithContext(
{
...createContext(),
body: { image: redirectUrl.ip },
},
async () => {
const error = await t.rejects(
async () =>
await request(redirectUrl.ip, {
maxRedirections: 1,
})
);
if (error instanceof Error) {
t.same(
error.message,
"Zen has blocked a server-side request forgery: undici.[method](...) originating from body.image"
);
}
}
);

await runWithContext(
{
...createContext(),
body: { image: redirectUrl.ipTwice },
},
async () => {
const error = await t.rejects(
async () =>
await request(redirectUrl.ipTwice, {
maxRedirections: 2,
})
);
if (error instanceof Error) {
t.same(
error.message,
"Zen has blocked a server-side request forgery: undici.[method](...) originating from body.image"
);
}
}
);

await runWithContext(
{
...createContext(),
body: { image: redirectUrl.domain },
},
async () => {
const error = await t.rejects(
async () =>
await request(redirectUrl.domain, {
maxRedirections: 2,
})
);
if (error instanceof Error) {
t.same(
error.message,
"Zen has blocked a server-side request forgery: undici.[method](...) originating from body.image"
);
}
}
);

await runWithContext(
{
...createContext(),
body: { image: redirectUrl.domainTwice },
},
async () => {
const error = await t.rejects(
async () =>
await request(redirectUrl.domainTwice, {
maxRedirections: 2,
})
);
if (error instanceof Error) {
t.same(
error.message,
"Zen has blocked a server-side request forgery: undici.[method](...) originating from body.image"
);
}
}
);

await runWithContext(
{
...createContext(),
body: {
image: `${redirectTestUrl2}/ssrf-test-absolute-domain`,
},
},
async () => {
const error = await t.rejects(
async () =>
await request(`${redirectTestUrl2}/ssrf-test-absolute-domain`, {
maxRedirections: 2,
})
);
if (error instanceof Error) {
t.same(
error.message,
"Zen has blocked a server-side request forgery: undici.[method](...) originating from body.image"
);
}
}
);

logger.clear();
setGlobalDispatcher(new UndiciAgent({}));
t.same(logger.getMessages(), [
Expand Down
33 changes: 31 additions & 2 deletions library/sinks/Undici.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { lookup } from "dns";
import { Agent } from "../agent/Agent";
import { getInstance } from "../agent/AgentSingleton";
import { getContext } from "../agent/Context";
import { bindContext, getContext } from "../agent/Context";
import { Hooks } from "../agent/hooks/Hooks";
import { InterceptorResult } from "../agent/hooks/InterceptorResult";
import { Wrapper } from "../agent/Wrapper";
Expand All @@ -11,7 +11,9 @@
import { inspectDNSLookupCalls } from "../vulnerabilities/ssrf/inspectDNSLookupCalls";
import { wrapDispatch } from "./undici/wrapDispatch";
import { wrapExport } from "../agent/hooks/wrapExport";
import { wrapNewInstance } from "../agent/hooks/wrapNewInstance";
import { getHostnameAndPortFromArgs } from "./undici/getHostnameAndPortFromArgs";
import { wrapOnHeaders } from "./undici/wrapOnHeaders";

const methods = [
"request",
Expand Down Expand Up @@ -84,12 +86,33 @@
},
});

dispatcher.dispatch = wrapDispatch(dispatcher.dispatch, agent);
dispatcher.dispatch = wrapDispatch(dispatcher.dispatch, agent, false);

// We'll set a global dispatcher that will inspect the resolved IP address (and thus preventing TOCTOU attacks)
undiciModule.setGlobalDispatcher(dispatcher);
}

// Wrap the dispatch method of the redirect handler to block http calls to private IPs if it's a redirect
private patchRedirectHandler(instance: unknown) {
const agent = getInstance();
const context = getContext();

if (!agent || !context) {
return instance;
}

// @ts-expect-error No types for this
instance.dispatch = bindContext(
// @ts-expect-error No types for this
wrapDispatch(instance.dispatch, agent, false, context)
);

// @ts-expect-error No types for this
instance.onHeaders = wrapOnHeaders(instance.onHeaders, context);

return instance;
}

wrap(hooks: Hooks) {
if (!isVersionGreaterOrEqual("16.8.0", getSemverNodeVersion())) {
// Undici requires Node.js 16.8+ (due to web streams)
Expand Down Expand Up @@ -132,6 +155,12 @@
},
});
}
})
// Todo only working for undici v6 right now

Check failure on line 159 in library/sinks/Undici.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

Unexpected 'todo' comment: 'Todo only working for undici v6 right...'
.onFileRequire("lib/handler/redirect-handler.js", (exports, pkgInfo) => {
return wrapNewInstance(exports, undefined, pkgInfo, (instance) => {
return this.patchRedirectHandler(instance);
});
});
}
}
52 changes: 2 additions & 50 deletions library/sinks/http-request/onHTTPResponse.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { IncomingMessage } from "http";
import { Context, getContext, updateContext } from "../../agent/Context";
import { getPortFromURL } from "../../helpers/getPortFromURL";
import { Context } from "../../agent/Context";
import { isRedirectStatusCode } from "../../helpers/isRedirectStatusCode";
import { tryParseURL } from "../../helpers/tryParseURL";
import { findHostnameInContext } from "../../vulnerabilities/ssrf/findHostnameInContext";
import { getRedirectOrigin } from "../../vulnerabilities/ssrf/getRedirectOrigin";
import { addRedirectToContext } from "../../vulnerabilities/ssrf/addRedirectToContext";

export function onHTTPResponse(
source: URL,
Expand All @@ -26,49 +24,3 @@ export function onHTTPResponse(

addRedirectToContext(source, destination, context);
}

/**
* Adds redirects with user provided hostname / url to the context to prevent SSRF attacks with redirects.
*/
function addRedirectToContext(source: URL, destination: URL, context: Context) {
let redirectOrigin: URL | undefined;

const sourcePort = getPortFromURL(source);

// Check if the source hostname is in the context - is true if it's the first redirect in the chain and the user input is the source
const found = findHostnameInContext(source.hostname, context, sourcePort);

// If the source hostname is not in the context, check if it's a redirect in an already existing chain
if (!found && context.outgoingRequestRedirects) {
// Get initial source of the redirect chain (first redirect), if url is part of a redirect chain
redirectOrigin = getRedirectOrigin(
context.outgoingRequestRedirects,
source
);
}

// If it's 1. an initial redirect with user provided url or 2. a redirect in an existing chain, add it to the context
if (found || redirectOrigin) {
addRedirectToChain(source, destination, context);
}
}

function addRedirectToChain(source: URL, destination: URL, context: Context) {
const outgoingRedirects = context.outgoingRequestRedirects || [];
const alreadyAdded = outgoingRedirects.find(
(r) =>
r.source.toString() === source.toString() &&
r.destination.toString() === destination.toString()
);

if (alreadyAdded) {
return;
}

outgoingRedirects.push({
source,
destination,
});

updateContext(context, "outgoingRequestRedirects", outgoingRedirects);
}
44 changes: 40 additions & 4 deletions library/sinks/undici/RequestContextStorage.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,47 @@
import { AsyncLocalStorage } from "async_hooks";
import { Context } from "../../agent/Context";

export type UndiciRequestContext = {

Check warning on line 4 in library/sinks/undici/RequestContextStorage.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

exported declaration 'UndiciRequestContext' not used within other modules
url: URL;
port?: number;
isFetch?: boolean; // True if the request is a fetch request, false if it's an direct undici request
inContext?: Context; // Incoming request context
};

/**
* This storage is used to store the port of outgoing fetch / undici requests.
* This is used to check if ports match when we are inspecting the result of dns resolution.
* If the port does not match, it would be a false positive ssrf detection.
*
* Its also used to store the incoming context of a request, if the request is a redirect.
*/
export const RequestContextStorage = new AsyncLocalStorage<{
url: URL;
port?: number;
}>();
const RequestContextStorage = new AsyncLocalStorage<UndiciRequestContext>();

/**
* Run a function with the given undici request context.
* If the context is already set, the context is updated with the new values.
*/
export function runWithUndiciRequestContext<T>(
context: UndiciRequestContext,
fn: () => T
): T {
const current = RequestContextStorage.getStore();

if (current) {
current.url = context.url;
current.port = context.port;
current.isFetch = context.isFetch;
current.inContext = context.inContext;

return fn();
}

return RequestContextStorage.run(context, fn);
}

/**
* Get the current undici request context (outgoing request).
*/
export function getUndiciRequestContext(): UndiciRequestContext | undefined {
return RequestContextStorage.getStore();
}
27 changes: 27 additions & 0 deletions library/sinks/undici/getUrlFromOptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as t from "tap";
import { getUrlFromOptions } from "./getUrlFromOptions";

t.test("it works", async () => {
t.same(
getUrlFromOptions({
origin: "http://localhost:3000/",
path: "test?query=1",
}).href,
"http://localhost:3000/test?query=1"
);

t.same(
getUrlFromOptions({
origin: new URL("http://localhost:3000"),
path: "test?query=1",
}).href,
"http://localhost:3000/test?query=1"
);

t.same(
getUrlFromOptions({
origin: new URL("http://localhost:3000"),
}).href,
"http://localhost:3000/"
);
});
16 changes: 16 additions & 0 deletions library/sinks/undici/getUrlFromOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { tryParseURL } from "../../helpers/tryParseURL";

/**
* Get the URL from the options object of a Undici request.
*/
export function getUrlFromOptions(opts: any): URL | undefined {
if (typeof opts.origin === "string" && typeof opts.path === "string") {
return tryParseURL(opts.origin + opts.path);
} else if (opts.origin instanceof URL) {
if (typeof opts.path === "string") {
return tryParseURL(opts.origin.href + opts.path);
} else {
return opts.origin;
}
}
}
Loading
Loading