diff --git a/js/server_functions.js b/js/server_functions.js index b1b0b19c83..9d4aff4704 100644 --- a/js/server_functions.js +++ b/js/server_functions.js @@ -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} 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 @@ -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) { @@ -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); @@ -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}`); } @@ -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 }; diff --git a/tests/unit/functions/server_functions_spec.js b/tests/unit/functions/server_functions_spec.js index 979a6a633d..ebb1d67893 100644 --- a/tests/unit/functions/server_functions_spec.js +++ b/tests/unit/functions/server_functions_spec.js @@ -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", () => { @@ -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(() => {}), @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -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"); @@ -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); }); }); });