fix: Update validateUrlNotPrivate to match implementation in SSRF (#12636)

This commit is contained in:
Tom Moor
2026-06-08 19:35:46 -04:00
committed by GitHub
parent 709184ae0b
commit a2f9962958
2 changed files with 41 additions and 11 deletions
+35
View File
@@ -50,6 +50,41 @@ describe("validateUrlNotPrivate", () => {
).rejects.toThrow("is not allowed");
});
it.each([
["::ffff:169.254.169.254", "metadata via IPv4-mapped IPv6"],
["::ffff:127.0.0.1", "loopback via IPv4-mapped IPv6"],
["::ffff:10.0.0.1", "RFC1918 via IPv4-mapped IPv6"],
["::ffff:192.168.1.1", "RFC1918 via IPv4-mapped IPv6"],
["64:ff9b::a9fe:a9fe", "metadata via NAT64"],
["2002:a9fe:a9fe::", "metadata via 6to4"],
])("should reject %s in URL (%s)", async (address) => {
await expect(
validateUrlNotPrivate(`https://[${address}]/api`)
).rejects.toThrow("is not allowed");
});
it("should reject IPv4-mapped IPv6 address resolved via DNS", async () => {
lookupSpy.mockResolvedValue({
address: "::ffff:169.254.169.254",
family: 6,
});
await expect(
validateUrlNotPrivate("https://metadata.example.com")
).rejects.toThrow("is not allowed");
});
it("should reject carrier-grade NAT address", async () => {
await expect(
validateUrlNotPrivate("https://100.64.0.1/api")
).rejects.toThrow("is not allowed");
});
it("should reject IPv4-mapped IPv6 addresses outright", async () => {
await expect(
validateUrlNotPrivate("https://[::ffff:8.8.8.8]/")
).rejects.toThrow("is not allowed");
});
describe("with ALLOWED_PRIVATE_IP_ADDRESSES", () => {
it("should allow exact IP match", async () => {
env.ALLOWED_PRIVATE_IP_ADDRESSES = ["10.0.0.1"];
+6 -11
View File
@@ -7,15 +7,6 @@ import { InvalidRequestError } from "@server/errors";
const UrlIdLength = 10;
/** IP ranges that are not allowed for outbound requests. */
const privateRanges = new Set([
"private",
"loopback",
"linkLocal",
"uniqueLocal",
"unspecified",
]);
export const generateUrlId = () => randomString(UrlIdLength);
// Paths probed by vulnerability scanners.
@@ -53,7 +44,9 @@ export function isPrivateIP(ip: string): boolean {
if (!ipaddr.isValid(ip)) {
return false;
}
return privateRanges.has(ipaddr.parse(ip).range());
// Only globally-routable unicast addresses are permitted
return ipaddr.parse(ip).range() !== "unicast";
}
/**
@@ -102,7 +95,9 @@ function isAllowedPrivateIP(ip: string): boolean {
* @throws InternalError if the URL resolves to a private IP that is not allowed.
*/
export async function validateUrlNotPrivate(url: string) {
const { hostname } = new URL(url);
// URL.hostname keeps the square brackets around IPv6 literals (e.g.
// "[::1]"), which net.isIP does not accept, so strip them before checking.
const hostname = new URL(url).hostname.replace(/^\[|\]$/g, "");
if (net.isIP(hostname)) {
if (isPrivateIP(hostname) && !isAllowedPrivateIP(hostname)) {