From 9a74339087af59f3ac5b73176f23136160b797c7 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 11:37:05 +0500 Subject: [PATCH] Block v4-mapped IPv6 literals in SSRF validator --- src/lib/clean-pdf/__tests__/ssrf.test.ts | 14 +++++- src/lib/clean-pdf/ssrf.ts | 57 +++++++++++++++++++----- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/lib/clean-pdf/__tests__/ssrf.test.ts b/src/lib/clean-pdf/__tests__/ssrf.test.ts index f6fdecc..4ab54e0 100644 --- a/src/lib/clean-pdf/__tests__/ssrf.test.ts +++ b/src/lib/clean-pdf/__tests__/ssrf.test.ts @@ -7,8 +7,9 @@ describe("isPrivateAddress", () => { "172.16.0.1", "172.31.255.255", "192.168.1.1", "192.0.0.8", "198.18.0.1", "224.0.0.1", "255.255.255.255", "::1", "::", "fc00::1", "fd12:3456::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:127.0.0.1", + "::ffff:7f00:1", "::ffff:a00:1", "::ffff:c0a8:101", "::ffff:a9fe:a9fe", ]; - const publicIps = ["93.184.216.34", "8.8.8.8", "172.32.0.1", "2606:2800:220:1::1", "::ffff:8.8.8.8"]; + const publicIps = ["93.184.216.34", "8.8.8.8", "172.32.0.1", "2606:2800:220:1::1", "::ffff:8.8.8.8", "::ffff:808:808"]; it.each(privateIps)("блокирует %s", (ip) => expect(isPrivateAddress(ip)).toBe(true)); it.each(publicIps)("пропускает %s", (ip) => expect(isPrivateAddress(ip)).toBe(false)); @@ -55,4 +56,15 @@ describe("assertPublicUrl", () => { const failResolve = async () => { throw new Error("ENOTFOUND"); }; await expect(assertPublicUrl("https://nope.example/x", failResolve)).rejects.toThrow(BlockedUrlError); }); + + it("блокирует v4-mapped IPv6 в bracket-нотации (SSRF-обход)", async () => { + for (const raw of [ + "http://[::ffff:127.0.0.1]/", + "http://[::ffff:10.0.0.1]/", + "http://[::ffff:169.254.169.254]/", + "http://[::ffff:192.168.1.1]/", + ]) { + await expect(assertPublicUrl(raw, publicResolve)).rejects.toThrow(BlockedUrlError); + } + }); }); diff --git a/src/lib/clean-pdf/ssrf.ts b/src/lib/clean-pdf/ssrf.ts index ecd01c2..68b5b06 100644 --- a/src/lib/clean-pdf/ssrf.ts +++ b/src/lib/clean-pdf/ssrf.ts @@ -31,20 +31,57 @@ function isPrivateV4(ip: string): boolean { return V4_PRIVATE.some(([lo, hi]) => n >= lo && n <= hi); } +/** Разворачивает IPv6 (в т.ч. сжатый `::` и v4-mapped/embedded dotted) в 8 групп по 16 бит. */ +function expandV6(ip: string): number[] | null { + let s = ip.toLowerCase(); + + // Встроенный dotted-хвост (::ffff:127.0.0.1, ::127.0.0.1) → в hex-группы + const dotted = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (dotted) { + if (isIP(dotted[1]) !== 4) return null; + const n = v4ToInt(dotted[1]); + s = s.slice(0, dotted.index) + ((n >>> 16) & 0xffff).toString(16) + ":" + (n & 0xffff).toString(16); + } + + const halves = s.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const tail = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : []; + + let groups: string[]; + if (halves.length === 2) { + const missing = 8 - head.length - tail.length; + if (missing < 0) return null; + groups = [...head, ...Array(missing).fill("0"), ...tail]; + } else { + groups = head; + } + if (groups.length !== 8) return null; + + const nums = groups.map((g) => (g === "" ? 0 : parseInt(g, 16))); + if (nums.some((x) => Number.isNaN(x) || x < 0 || x > 0xffff)) return null; + return nums; +} + export function isPrivateAddress(ip: string): boolean { if (isIP(ip) === 4) return isPrivateV4(ip); if (isIP(ip) !== 6) return true; // не IP — не пропускаем - const lower = ip.toLowerCase(); - // v4-mapped: ::ffff:10.0.0.1 - const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (mapped) return isPrivateV4(mapped[1]); + const groups = expandV6(ip); + if (!groups) return true; // не смогли разобрать v6 — не пропускаем - if (lower === "::" || lower === "::1") return true; - // fc00::/7 (ULA), fe80::/10 (link-local) - const firstGroup = parseInt(lower.split(":")[0] || "0", 16); - if (firstGroup >= 0xfc00 && firstGroup <= 0xfdff) return true; - if (firstGroup >= 0xfe80 && firstGroup <= 0xfebf) return true; + // ::/96 (v4-compatible, вкл. :: и ::1) и ::ffff:0:0/96 (v4-mapped): + // младшие 32 бита содержат IPv4 — проверяем его напрямую. + const firstFiveZero = groups.slice(0, 5).every((g) => g === 0); + if (firstFiveZero && (groups[5] === 0 || groups[5] === 0xffff)) { + const v4num = groups[6] * 0x10000 + groups[7]; + const dottedV4 = `${(v4num >>> 24) & 255}.${(v4num >>> 16) & 255}.${(v4num >>> 8) & 255}.${v4num & 255}`; + return isPrivateV4(dottedV4); + } + + const first = groups[0]; + if (first >= 0xfc00 && first <= 0xfdff) return true; // fc00::/7 (ULA) + if (first >= 0xfe80 && first <= 0xfebf) return true; // fe80::/10 (link-local) return false; } @@ -59,7 +96,7 @@ export async function assertPublicUrl(raw: string, resolve: LookupFn = lookup): throw new BlockedUrlError("Поддерживаются только http и https"); } - const hostname = url.hostname.replace(/^\[|\]$/g, ""); // [::1] → ::1 + const hostname = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, ""); // [::1] → ::1 if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal")) { throw new BlockedUrlError("Адрес недоступен"); }