diff --git a/js/server_functions.mjs b/js/server_functions.mjs index 99e1a9ea13..f4159a71d6 100644 --- a/js/server_functions.mjs +++ b/js/server_functions.mjs @@ -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} 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. * @@ -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 }); } }; diff --git a/tests/unit/functions/server_functions_spec.mjs b/tests/unit/functions/server_functions_spec.mjs index b6903ff020..ff31ae030a 100644 --- a/tests/unit/functions/server_functions_spec.mjs +++ b/tests/unit/functions/server_functions_spec.mjs @@ -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); @@ -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 () => { @@ -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", () => { @@ -314,12 +310,7 @@ 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 () => { @@ -327,11 +318,100 @@ describe("server_functions tests", () => { 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 }); }); }); });