Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
95 changes: 58 additions & 37 deletions js/server_functions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,39 @@ const replaceSecretPlaceholder = (input, allowedSecrets) => {
});
};

/**
* Validates that a hostname is allowed by the CORS configuration and resolves to a public unicast IP.
* @param {string} hostname - The hostname to validate.
* @returns {Promise<string>} The validated IP address.
*/
const validateCorsHost = async (hostname) => {
const host = hostname.toLowerCase();

if (host === "localhost") {
Log.warn(`SSRF blocked (localhost): ${hostname}`);
const error = new Error("Forbidden: private or reserved addresses are not allowed");
error.statusCode = 403;
throw error;
}

if (global.config.cors === "allowWhitelist" && !global.config.corsDomainWhitelist.includes(host)) {
Log.warn(`CORS blocked (not in whitelist): ${hostname}`);
const error = new Error("Forbidden: domain not in corsDomainWhitelist");
error.statusCode = 403;
throw error;
}

const { address } = await dns.promises.lookup(host);
if (ipaddr.process(address).range() !== "unicast") {
Log.warn(`SSRF blocked: ${hostname}`);
const error = new Error("Forbidden: private or reserved addresses are not allowed");
error.statusCode = 403;
throw error;
}

return address;
};

/**
* A method that forwards HTTP Get-methods to the internet to avoid CORS-errors.
*
Expand Down Expand Up @@ -85,58 +118,46 @@ const cors = async (req, res) => {
return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" });
}

// Block localhost by hostname before even creating the dispatcher (no DNS needed).
if (parsed.hostname.toLowerCase() === "localhost") {
Log.warn(`SSRF blocked (localhost): ${url}`);
return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" });
}

// Whitelist check: if enabled, only allow explicitly listed domains
if (global.config.cors === "allowWhitelist" && !global.config.corsDomainWhitelist.includes(parsed.hostname.toLowerCase())) {
Log.warn(`CORS blocked (not in whitelist): ${url}`);
return res.status(403).json({ error: "Forbidden: domain not in corsDomainWhitelist" });
}

const headersToSend = getHeadersToSend(req.url);
const expectedReceivedHeaders = geExpectedReceivedHeaders(req.url);
Log.log(`cors url: ${url}`);

// Resolve DNS once and validate the IP. The validated IP is then pinned
// for the actual connection so fetch() cannot re-resolve to a different
// address. This prevents DNS rebinding / TOCTOU attacks (GHSA-xhvw-r95j-xm4v).
const { address, family } = await dns.promises.lookup(parsed.hostname);
if (ipaddr.process(address).range() !== "unicast") {
Log.warn(`SSRF blocked: ${url}`);
return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" });
}

// Pin the validated IP — fetch() reuses it instead of doing its own DNS lookup
// Validate each host immediately before connecting and pin the socket to its public IP.
const defaultConnector = undici.buildConnector({});
const dispatcher = new undici.Agent({
connect: {
lookup: (_h, _o, cb) => {
const addresses = [{ address: address, family: family }];
process.nextTick(() => cb(null, addresses));
connect: async (options, callback) => {
try {
const address = await validateCorsHost(options.hostname);
// Keep the original hostname for TLS SNI while connecting to the validated IP.
defaultConnector({ ...options, hostname: address, servername: options.servername || options.hostname }, callback);
} catch (err) {
callback(err, null);
}
}
});

const response = await undici.fetch(url, { dispatcher, headers: headersToSend });
if (response.ok) {
for (const header of expectedReceivedHeaders) {
const headerValue = response.headers.get(header);
if (header) res.set(header, headerValue);
try {
const response = await undici.fetch(url, { dispatcher, headers: headersToSend });
if (response.ok) {
for (const header of expectedReceivedHeaders) {
const headerValue = response.headers.get(header);
if (header) res.set(header, headerValue);
}
const arrayBuffer = await response.arrayBuffer();
res.send(Buffer.from(arrayBuffer));
} else {
throw new Error(`Response status: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
res.send(Buffer.from(arrayBuffer));
} else {
throw new Error(`Response status: ${response.status}`);
} finally {
await dispatcher.close();
}
}
} catch (error) {
const cause = error.cause?.statusCode ? error.cause : error;
if (process.env.mmTestMode !== "true") {
Log.error(`Error in CORS request: ${error}`);
Log.error(`Error in CORS request: ${cause}`);
}
res.status(500).json({ error: error.message });
res.status(cause.statusCode || 500).json({ error: cause.message });
}
};

Expand Down
120 changes: 100 additions & 20 deletions tests/unit/functions/server_functions_spec.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { createRequire } from "node:module";
import dns from "node:dns";
import http from "node:http";
import undici from "undici";
import { cors, getUserAgent, replaceSecretPlaceholder } from "#server_functions";

const require = createRequire(import.meta.url);
const commonJSExports = require("#server_functions");

const realUndiciFetch = undici.fetch.bind(undici);
const realBuildConnector = undici.buildConnector.bind(undici);

describe("server_functions tests", () => {
it("preserves the CommonJS API", () => {
expect(Object.getPrototypeOf(commonJSExports)).toBe(Object.prototype);
Expand Down Expand Up @@ -256,12 +260,14 @@ describe("server_functions tests", () => {
status: vi.fn(function () { return this; }),
json: vi.fn()
};
vi.spyOn(undici, "fetch").mockImplementation((...args) => realUndiciFetch(...args));
});

it("Blocks localhost hostname without DNS", async () => {
await cors({ url: "/cors?url=http://localhost/path" }, response);
expect(response.status).toHaveBeenCalledWith(403);
expect(response.json).toHaveBeenCalledWith({ error: "Forbidden: private or reserved addresses are not allowed" });
expect(dns.promises.lookup).not.toHaveBeenCalled();
});

it("Blocks non-http protocols", async () => {
Expand Down Expand Up @@ -292,16 +298,6 @@ describe("server_functions tests", () => {
expect(response.status).toHaveBeenCalledWith(403);
});

it("Allows public unicast addresses", async () => {
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 });
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
headers: { get: vi.fn() },
arrayBuffer: vi.fn(() => new ArrayBuffer(0))
});
await cors({ url: "/cors?url=http://example.com/" }, response);
expect(response.status).not.toHaveBeenCalledWith(403);
});
});

describe("cors method with allowWhitelist", () => {
Expand All @@ -314,24 +310,108 @@ describe("server_functions tests", () => {
status: vi.fn(function () { return this; }),
json: vi.fn()
};
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 });
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
headers: { get: vi.fn() },
arrayBuffer: vi.fn(() => new ArrayBuffer(0))
});
vi.spyOn(undici, "fetch").mockImplementation((...args) => realUndiciFetch(...args));
});

it("Blocks domains not in whitelist", async () => {
global.config = { cors: "allowWhitelist", corsDomainWhitelist: [] };
await cors({ url: "/cors?url=http://example.com/api" }, response);
expect(response.status).toHaveBeenCalledWith(403);
});
});

it("Allows domains in whitelist", async () => {
global.config = { cors: "allowWhitelist", corsDomainWhitelist: ["example.com"] };
await cors({ url: "/cors?url=http://example.com/api" }, response);
expect(response.status).not.toHaveBeenCalledWith(403);
describe("The cors method validates redirect targets", () => {
let server;
let port;
let response;

beforeEach(async () => {
global.config = { cors: "allowAll" };
response = {
set: vi.fn(),
send: vi.fn(),
status: vi.fn(function () { return this; }),
json: vi.fn()
};

vi.spyOn(undici, "fetch").mockImplementation((...args) => realUndiciFetch(...args));
// Keep the request hostname for CORS checks, but route its socket to the local test server.
vi.spyOn(undici, "buildConnector").mockImplementation((connectorOptions) => {
const connector = realBuildConnector(connectorOptions);
return (options, callback) => connector({ ...options, hostname: "127.0.0.1", servername: null }, callback);
});

server = http.createServer((req, res) => {
const redirects = {
"/redirect-allowed": `http://second.test:${port}/final`,
"/redirect-private-domain": `http://internal.test:${port}/secret`,
"/redirect-loopback-ip": `http://127.0.0.1:${port}/secret`,
"/redirect-outside-whitelist": `http://outside.test:${port}/final`
};
if (redirects[req.url]) {
res.writeHead(302, { Location: redirects[req.url] });
res.end();
return;
}
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("redirect-ok");
});

await new Promise((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
({ port } = server.address());
});

afterEach(async () => {
await new Promise((resolve) => {
server.close(resolve);
});
});

it("Follows redirects when all hops resolve to public unicast addresses and are whitelisted", async () => {
global.config = { cors: "allowWhitelist", corsDomainWhitelist: ["first.test", "second.test"] };
const lookupSpy = vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 });

await cors({ url: `/cors?url=http://first.test:${port}/redirect-allowed` }, response);

expect(lookupSpy.mock.calls.map(([hostname]) => hostname)).toEqual(["first.test", "second.test"]);
expect(response.send).toHaveBeenCalledWith(Buffer.from("redirect-ok"));
});

it.each([
{
name: "a private DNS result",
path: "/redirect-private-domain",
config: { cors: "allowAll" },
addresses: ["93.184.216.34", "127.0.0.1"],
error: "Forbidden: private or reserved addresses are not allowed"
},
{
name: "a loopback IP literal",
path: "/redirect-loopback-ip",
config: { cors: "allowAll" },
addresses: ["93.184.216.34", "127.0.0.1"],
error: "Forbidden: private or reserved addresses are not allowed"
},
{
name: "a host outside the whitelist",
path: "/redirect-outside-whitelist",
config: { cors: "allowWhitelist", corsDomainWhitelist: ["first.test"] },
addresses: ["93.184.216.34"],
error: "Forbidden: domain not in corsDomainWhitelist"
}
])("Blocks redirects to $name", async ({ path, config, addresses, error }) => {
global.config = config;
const lookupAddresses = [...addresses];
vi.spyOn(dns.promises, "lookup").mockImplementation(() => {
return Promise.resolve({ address: lookupAddresses.shift(), family: 4 });
});

await cors({ url: `/cors?url=http://first.test:${port}${path}` }, response);

expect(response.status).toHaveBeenCalledWith(403);
expect(response.json).toHaveBeenCalledWith({ error: error });
});
});
});
Loading