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
40 changes: 39 additions & 1 deletion js/server_functions.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,42 @@
const dns = require("node:dns").promises;
const fs = require("node:fs");
const path = require("node:path");
const ipaddr = require("ipaddr.js");
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;

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 @@ -52,6 +85,11 @@ async function cors (req, res) {
}
}

if (await isPrivateTarget(url)) {
Log.warn(`SSRF blocked: ${url}`);
return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" });
}

const headersToSend = getHeadersToSend(req.url);
const expectedReceivedHeaders = geExpectedReceivedHeaders(req.url);
Log.log(`cors url: ${url}`);
Expand Down Expand Up @@ -202,4 +240,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 };
module.exports = { cors, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, getConfigFilePath, replaceSecretPlaceholder, isPrivateTarget };
85 changes: 83 additions & 2 deletions tests/unit/functions/server_functions_spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
const { cors, getUserAgent, replaceSecretPlaceholder } = require("#server_functions");
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
}
}));

describe("server_functions tests", () => {
describe("The replaceSecretPlaceholder method", () => {
Expand Down Expand Up @@ -53,7 +61,7 @@ describe("server_functions tests", () => {
};

request = {
url: "/cors?url=www.test.com"
url: "/cors?url=http://www.test.com"
};
});

Expand Down Expand Up @@ -182,4 +190,77 @@ describe("server_functions tests", () => {
global.config = previousConfig;
});
});

describe("The isPrivateTarget method", () => {
beforeEach(() => {
mockLookup.mockReset();
});

it("Blocks unparseable URLs", async () => {
expect(await isPrivateTarget("not a url")).toBe(true);
});

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);
});

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 link-local addresses", async () => {
mockLookup.mockResolvedValue([{ address: "169.254.169.254", family: 4 }]);
expect(await isPrivateTarget("http://metadata.example.com/")).toBe(true);
});

it("Blocks when DNS lookup fails", async () => {
mockLookup.mockRejectedValue(new Error("ENOTFOUND"));
expect(await isPrivateTarget("http://nonexistent.invalid/")).toBe(true);
});

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 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);
});
});

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 }]);

const request = { url: "/cors?url=http://127.0.0.1:8080/config" };
const 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" });
});
});
});
Loading