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
78 changes: 39 additions & 39 deletions js/server_functions.js
Original file line number Diff line number Diff line change
@@ -1,43 +1,12 @@
const dns = require("node:dns").promises;
const dns = require("node:dns");
const fs = require("node:fs");
const path = require("node:path");
const ipaddr = require("ipaddr.js");
const { Agent } = require("undici");
const Log = require("logger");

const startUp = new Date();

/**
* Checks whether a URL targets a private, reserved, or otherwise non-globally-routable address.
* Used to prevent SSRF (Server-Side Request Forgery) via the /cors proxy endpoint.
* @param {string} url - The URL to check.
* @returns {Promise<boolean>} true if the target is private/reserved and should be blocked.
*/
async function isPrivateTarget (url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return true;
}

if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return true;

const hostname = parsed.hostname.replace(/^\[|\]$/g, "");

if (hostname.toLowerCase() === "localhost") return true;
if (global.config.cors === "allowWhitelist" && !global.config.corsDomainWhitelist.includes(hostname.toLowerCase())) return true;

try {
const results = await dns.lookup(hostname, { all: true });
for (const { address } of results) {
if (ipaddr.process(address).range() !== "unicast") return true;
}
} catch {
return true;
}
return false;
}

/**
* Gets the startup time.
* @param {Request} req - the request
Expand Down Expand Up @@ -73,9 +42,9 @@ async function cors (req, res) {
Log.error("CORS is disabled, you need to enable it in `config.js` by setting `cors` to `allowAll` or `allowWhitelist`");
return res.status(403).json({ error: "CORS proxy is disabled" });
}
let url;
try {
const urlRegEx = "url=(.+?)$";
let url;

const match = new RegExp(urlRegEx, "g").exec(req.url);
if (!match) {
Expand All @@ -90,16 +59,48 @@ async function cors (req, res) {
}
}

if (await isPrivateTarget(url)) {
Log.warn(`SSRF blocked: ${url}`);
// Validate protocol before attempting connection (non-http/https are never allowed)
let parsed;
try {
parsed = new URL(url);
} catch {
Log.warn(`SSRF blocked (invalid URL): ${url}`);
return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" });
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
Log.warn(`SSRF blocked (protocol): ${url}`);
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}`);

const response = await fetch(url, { headers: headersToSend });
// 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
const dispatcher = new Agent({ connect: { lookup: (_h, _o, cb) => cb(null, address, family) } });

const response = await fetch(url, { dispatcher, headers: headersToSend });
if (response.ok) {
for (const header of expectedReceivedHeaders) {
const headerValue = response.headers.get(header);
Expand All @@ -112,7 +113,6 @@ async function cors (req, res) {
}
}
} catch (error) {
// Only log errors in non-test environments to keep test output clean
if (process.env.mmTestMode !== "true") {
Log.error(`Error in CORS request: ${error}`);
}
Expand Down Expand Up @@ -245,4 +245,4 @@ function getConfigFilePath () {
return path.resolve(global.configuration_file || `${global.root_path}/config/config.js`);
}

module.exports = { cors, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, getConfigFilePath, replaceSecretPlaceholder, isPrivateTarget };
module.exports = { cors, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, getConfigFilePath, replaceSecretPlaceholder };
174 changes: 85 additions & 89 deletions tests/unit/functions/server_functions_spec.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
const { cors, getUserAgent, replaceSecretPlaceholder, isPrivateTarget } = require("#server_functions");

const mockLookup = vi.fn(() => Promise.resolve([{ address: "93.184.216.34", family: 4 }]));

vi.mock("node:dns", () => ({
promises: {
lookup: mockLookup
}
}));
// Tests use vi.spyOn on shared module objects (dns, global.fetch).
// vi.spyOn modifies the object property directly on the cached module instance, so it
// is intercepted by server_functions.js regardless of the Module.prototype.require override
// in vitest-setup.js. restoreAllMocks:true auto-restores spies, but may reuse the same
// spy instance — mockClear() is called explicitly in beforeEach to reset call history.
const dns = require("node:dns");
const { cors, getUserAgent, replaceSecretPlaceholder } = require("#server_functions");

describe("server_functions tests", () => {
describe("The replaceSecretPlaceholder method", () => {
Expand All @@ -27,29 +25,29 @@ describe("server_functions tests", () => {
});

describe("The cors method", () => {
let fetchResponse;
let fetchSpy;
let fetchResponseHeadersGet;
let fetchResponseArrayBuffer;
let corsResponse;
let request;
let fetchMock;

beforeEach(() => {
global.config = { cors: "allowAll" };
fetchResponseHeadersGet = vi.fn(() => {});
fetchResponseArrayBuffer = vi.fn(() => {});
fetchResponse = {
headers: {
get: fetchResponseHeadersGet
},
arrayBuffer: fetchResponseArrayBuffer,
ok: true
};

fetch = vi.fn();
fetch.mockImplementation(() => fetchResponse);
// Mock DNS to return a public IP (SSRF check must pass for these tests)
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 });

fetchMock = fetch;
// vi.spyOn may return the same spy instance across tests when restoreAllMocks
// restores-but-reuses; mockClear() explicitly resets call history each time.
fetchSpy = vi.spyOn(global, "fetch");
fetchSpy.mockClear();
fetchSpy.mockImplementation(() => Promise.resolve({
headers: { get: fetchResponseHeadersGet },
arrayBuffer: fetchResponseArrayBuffer,
ok: true
}));

corsResponse = {
set: vi.fn(() => {}),
Expand All @@ -72,8 +70,8 @@ describe("server_functions tests", () => {

await cors(request, corsResponse);

expect(fetchMock.mock.calls).toHaveLength(1);
expect(fetchMock.mock.calls[0][0]).toBe(urlToCall);
expect(fetchSpy.mock.calls).toHaveLength(1);
expect(fetchSpy.mock.calls[0][0]).toBe(urlToCall);
});

it("Forwards Content-Type if json", async () => {
Expand Down Expand Up @@ -135,9 +133,9 @@ describe("server_functions tests", () => {
it("Fetches with user agent by default", async () => {
await cors(request, corsResponse);

expect(fetchMock.mock.calls).toHaveLength(1);
expect(fetchMock.mock.calls[0][1]).toHaveProperty("headers");
expect(fetchMock.mock.calls[0][1].headers).toHaveProperty("User-Agent");
expect(fetchSpy.mock.calls).toHaveLength(1);
expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers");
expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("User-Agent");
});

it("Fetches with specified headers", async () => {
Expand All @@ -147,10 +145,10 @@ describe("server_functions tests", () => {

await cors(request, corsResponse);

expect(fetchMock.mock.calls).toHaveLength(1);
expect(fetchMock.mock.calls[0][1]).toHaveProperty("headers");
expect(fetchMock.mock.calls[0][1].headers).toHaveProperty("header1", "value1");
expect(fetchMock.mock.calls[0][1].headers).toHaveProperty("header2", "value2");
expect(fetchSpy.mock.calls).toHaveLength(1);
expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers");
expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("header1", "value1");
expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("header2", "value2");
});

it("Sends specified headers", async () => {
Expand All @@ -162,8 +160,8 @@ describe("server_functions tests", () => {

await cors(request, corsResponse);

expect(fetchMock.mock.calls).toHaveLength(1);
expect(fetchMock.mock.calls[0][1]).toHaveProperty("headers");
expect(fetchSpy.mock.calls).toHaveLength(1);
expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers");
expect(corsResponse.set.mock.calls).toHaveLength(3);
expect(corsResponse.set.mock.calls[0][0]).toBe("Content-Type");
expect(corsResponse.set.mock.calls[1][0]).toBe("header1");
Expand Down Expand Up @@ -192,94 +190,92 @@ describe("server_functions tests", () => {
});
});

describe("The isPrivateTarget method", () => {
describe("The cors method blocks SSRF (DNS rebinding safe)", () => {
let response;

beforeEach(() => {
mockLookup.mockReset();
response = {
set: vi.fn(),
send: vi.fn(),
status: vi.fn(function () { return this; }),
json: vi.fn()
};
});

it("Blocks unparseable URLs", async () => {
expect(await isPrivateTarget("not a url")).toBe(true);
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" });
});

it("Blocks non-http protocols", async () => {
expect(await isPrivateTarget("file:///etc/passwd")).toBe(true);
expect(await isPrivateTarget("ftp://internal/file")).toBe(true);
});

it("Blocks localhost", async () => {
expect(await isPrivateTarget("http://localhost/path")).toBe(true);
expect(await isPrivateTarget("http://LOCALHOST:8080/")).toBe(true);
});

it("Blocks private IPs (loopback)", async () => {
mockLookup.mockResolvedValue([{ address: "127.0.0.1", family: 4 }]);
expect(await isPrivateTarget("http://loopback.example.com/")).toBe(true);
await cors({ url: "/cors?url=ftp://example.com/file" }, response);
expect(response.status).toHaveBeenCalledWith(403);
});

it("Blocks private IPs (RFC 1918)", async () => {
mockLookup.mockResolvedValue([{ address: "192.168.1.1", family: 4 }]);
expect(await isPrivateTarget("http://internal.example.com/")).toBe(true);
it("Blocks invalid URLs", async () => {
await cors({ url: "/cors?url=not_a_valid_url" }, response);
expect(response.status).toHaveBeenCalledWith(403);
});

it("Blocks link-local addresses", async () => {
mockLookup.mockResolvedValue([{ address: "169.254.169.254", family: 4 }]);
expect(await isPrivateTarget("http://metadata.example.com/")).toBe(true);
it("Blocks loopback addresses (127.0.0.1)", async () => {
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "127.0.0.1", family: 4 });
await cors({ url: "/cors?url=http://example.com/" }, response);
expect(response.status).toHaveBeenCalledWith(403);
});

it("Blocks when DNS lookup fails", async () => {
mockLookup.mockRejectedValue(new Error("ENOTFOUND"));
expect(await isPrivateTarget("http://nonexistent.invalid/")).toBe(true);
it("Blocks RFC 1918 private addresses (192.168.x.x)", async () => {
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "192.168.1.1", family: 4 });
await cors({ url: "/cors?url=http://example.com/" }, response);
expect(response.status).toHaveBeenCalledWith(403);
});

it("Allows public unicast IPs", async () => {
mockLookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
expect(await isPrivateTarget("http://example.com/api")).toBe(false);
it("Blocks link-local / cloud metadata addresses (169.254.169.254)", async () => {
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "169.254.169.254", family: 4 });
await cors({ url: "/cors?url=http://example.com/" }, response);
expect(response.status).toHaveBeenCalledWith(403);
});

it("Blocks if any resolved address is private", async () => {
mockLookup.mockResolvedValue([
{ address: "93.184.216.34", family: 4 },
{ address: "127.0.0.1", family: 4 }
]);
expect(await isPrivateTarget("http://dual.example.com/")).toBe(true);
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("The cors method blocks SSRF", () => {
it("Returns 403 for private target URLs", async () => {
mockLookup.mockReset();
mockLookup.mockResolvedValue([{ address: "127.0.0.1", family: 4 }]);
describe("cors method with allowWhitelist", () => {
let response;

const request = { url: "/cors?url=http://127.0.0.1:8080/config" };
const response = {
beforeEach(() => {
response = {
set: vi.fn(),
send: vi.fn(),
status: vi.fn(function () { return this; }),
json: vi.fn()
};

await cors(request, response);

expect(response.status).toHaveBeenCalledWith(403);
expect(response.json).toHaveBeenCalledWith({ error: "Forbidden: private or reserved addresses are not allowed" });
});
});

describe("The isPrivateTarget method with allowWhitelist", () => {
beforeEach(() => {
mockLookup.mockReset();
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))
});
});

it("Block public unicast IPs if not whitelistet", async () => {
it("Blocks domains not in whitelist", async () => {
global.config = { cors: "allowWhitelist", corsDomainWhitelist: [] };
mockLookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
expect(await isPrivateTarget("http://example.com/api")).toBe(true);
await cors({ url: "/cors?url=http://example.com/api" }, response);
expect(response.status).toHaveBeenCalledWith(403);
});

it("Allow public unicast IPs if whitelistet", async () => {
it("Allows domains in whitelist", async () => {
global.config = { cors: "allowWhitelist", corsDomainWhitelist: ["example.com"] };
mockLookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
expect(await isPrivateTarget("http://example.com/api")).toBe(false);
await cors({ url: "/cors?url=http://example.com/api" }, response);
expect(response.status).not.toHaveBeenCalledWith(403);
});
});
});
Loading