From 196c508cc9b1fa0532c9121d239b68ee04296fe0 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 29 Jun 2026 09:10:16 +0200 Subject: [PATCH] fix(security): validate feed/URI URLs and cap download size Feed enclosure URLs () and obsidian://podnotes deep-link URLs are fully attacker-controlled, yet flowed straight into requestUrl with no scheme or host validation - a blind SSRF primitive (plus a file:/data: local-read/exfil path via the transcription fetch). The streaming download also had no total-size cap, so a malicious media server could fill the disk. - Add src/utility/assertFetchableUrl.ts: allow only http(s); reject loopback/link-local/private/cloud-metadata hosts (IPv4 + IPv6, including obfuscated, IPv4-mapped, and absolute-FQDN forms). - Route every feed/URI-derived fetch through it: downloadFile and the getFileExtension HEAD probe (downloadEpisode.ts), probeAndFetchFirstChunk and writeStreamedFile (download/streaming.ts), and the feed-fetch branch of the obsidian://podnotes handler (URIHandler.ts). - Cap the streamed download at MAX_DOWNLOAD_SIZE (2 GiB): reject an oversized advertised total or 200-fallback body up front, and abort the 206 loop once the running total exceeds the cap (stops the unknown-total infinite-stream disk-fill). deepsec: ssrf-2306e54f4d, ssrf-47eda4095e, ssrf-594c984897, other-resource-exhaustion-54d62080a1 --- src/URIHandler.test.ts | 27 ++++ src/URIHandler.ts | 12 ++ src/download/streaming.test.ts | 83 +++++++++++ src/download/streaming.ts | 45 ++++++ src/downloadEpisode.test.ts | 17 +++ src/downloadEpisode.ts | 3 + src/utility/assertFetchableUrl.test.ts | 98 +++++++++++++ src/utility/assertFetchableUrl.ts | 189 +++++++++++++++++++++++++ 8 files changed, 474 insertions(+) create mode 100644 src/utility/assertFetchableUrl.test.ts create mode 100644 src/utility/assertFetchableUrl.ts diff --git a/src/URIHandler.test.ts b/src/URIHandler.test.ts index 7bafb714..15acd895 100644 --- a/src/URIHandler.test.ts +++ b/src/URIHandler.test.ts @@ -171,6 +171,33 @@ describe("podNotesURIHandler", () => { expect(get(viewState)).toBe(ViewState.PodcastGrid); }); + test.each([ + "http://169.254.169.254/latest/meta-data/", + "http://127.0.0.1:8080/feed.xml", + "http://192.168.0.1/feed.xml", + ])( + "refuses a deep link whose url points at an internal host (%s) without fetching", + async (url) => { + const revealPlayer = vi.fn(); + + await podNotesURIHandler( + { + action: "podnotes", + url, + episodeName: "Some Episode", + }, + api as never, + revealPlayer, + ); + + // The attacker-controlled url is never handed to the feed parser. + expect(mockGetEpisodes).not.toHaveBeenCalled(); + expect(get(currentEpisode)).toBeUndefined(); + expect(get(viewState)).toBe(ViewState.PodcastGrid); + expect(revealPlayer).not.toHaveBeenCalled(); + }, + ); + test("keeps the requested segment end for the player to apply after loading metadata", async () => { await podNotesURIHandler( { diff --git a/src/URIHandler.ts b/src/URIHandler.ts index 1848d243..0c94704b 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -16,6 +16,7 @@ import { viewState, } from "./store"; import type { Episode } from "./types/Episode"; +import { isFetchableUrl } from "./utility/assertFetchableUrl"; import { getEpisodeKey } from "./utility/episodeKey"; import { ViewState } from "./types/ViewState"; @@ -178,6 +179,17 @@ export default async function podNotesURIHandler( .map((name) => localFiles.getLocalEpisode(name)) .find((ep) => ep !== undefined); } else { + // The url here came straight from an untrusted obsidian://podnotes deep link + // (an attacker can put one behind on a web page), so a single click + // must not be able to fetch an arbitrary internal host. Refuse anything that + // isn't a public http(s) URL before handing it to FeedParser (blind SSRF). + if (!isFetchableUrl(url)) { + new Notice( + "Refusing to load a feed from a private, local, or non-http(s) URL", + ); + return; + } + try { // Fetch with the raw url (current-format links are correct as-is); only the title gets // the legacy-candidate treatment. A '+' in a legacy feed URL is pre-existing and out of diff --git a/src/download/streaming.test.ts b/src/download/streaming.test.ts index 1b2cec12..c5ef5212 100644 --- a/src/download/streaming.test.ts +++ b/src/download/streaming.test.ts @@ -188,6 +188,89 @@ describe("writeStreamedFile", () => { }); }); +describe("download size cap (resource exhaustion)", () => { + it("rejects a 206 whose advertised total exceeds the cap, up front", async () => { + requestUrlMock.mockResolvedValue( + res(206, [1, 2, 3, 4], { + "content-type": "audio/mpeg", + "content-range": "bytes 0-3/1099511627776", // 1 TiB + }), + ); + + await expect( + probeAndFetchFirstChunk("https://x/ep.mp3", 4, 100), + ).rejects.toThrow(/maximum allowed size/); + }); + + it("rejects a 200 fallback whose whole body exceeds the cap", async () => { + // Server ignores Range and returns the entire (oversized) body in one 200. + requestUrlMock.mockResolvedValue( + res(200, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], { "content-type": "audio/mpeg" }), + ); + + await expect( + probeAndFetchFirstChunk("https://x/ep.mp3", 4, 5), + ).rejects.toThrow(/maximum allowed size/); + }); + + it("aborts the 206 loop once the running total exceeds the cap (unknown-total infinite stream)", async () => { + const a = setupAdapter(); + // A malicious server with an unknown total ("/*") that returns full-size + // chunks forever — only the running-total cap can stop it. + requestUrlMock.mockResolvedValue(res(206, [9, 9])); + + await expect( + writeStreamedFile( + "https://x/ep.mp3", + "out.mp3", + probe({ totalSize: null, firstChunk: new Uint8Array([1, 2]).buffer }), + undefined, + 2, // chunkSize + 5, // maxSize + ), + ).rejects.toThrow(/maximum allowed size/); + + // It stopped instead of writing without bound. + expect((a.writes.get("out.mp3")?.length ?? 0)).toBeLessThanOrEqual(5 + 2); + }); + + it("rejects when even the first chunk already exceeds the cap", async () => { + const a = setupAdapter(); + + await expect( + writeStreamedFile( + "https://x/ep.mp3", + "out.mp3", + probe({ firstChunk: new Uint8Array([1, 2, 3, 4, 5, 6]).buffer, supportsRange: false }), + undefined, + 2, + 5, + ), + ).rejects.toThrow(/maximum allowed size/); + expect(a.writeBinary).not.toHaveBeenCalled(); + }); +}); + +describe("SSRF guard", () => { + it.each([ + "http://169.254.169.254/latest/meta-data/", + "http://127.0.0.1:8080/ep.mp3", + "file:///Users/victim/.ssh/id_rsa", + ])("probeAndFetchFirstChunk refuses %s without issuing a request", async (url) => { + await expect(probeAndFetchFirstChunk(url, 4)).rejects.toThrow(/Refusing/); + expect(requestUrlMock).not.toHaveBeenCalled(); + }); + + it("writeStreamedFile refuses a blocked URL before touching the adapter", async () => { + const a = setupAdapter(); + await expect( + writeStreamedFile("http://192.168.0.1/ep.mp3", "out.mp3", probe(), undefined, 2), + ).rejects.toThrow(/Refusing/); + expect(a.writeBinary).not.toHaveBeenCalled(); + expect(requestUrlMock).not.toHaveBeenCalled(); + }); +}); + describe("partialPathFor / isPartialPath", () => { it("builds a dot-prefixed sibling temp in the same folder", () => { const tmp = partialPathFor("Podcasts/Show/Ep 1.mp3"); diff --git a/src/download/streaming.ts b/src/download/streaming.ts index 8700c2ea..3e2edf51 100644 --- a/src/download/streaming.ts +++ b/src/download/streaming.ts @@ -1,6 +1,7 @@ import { type DataAdapter, requestUrl } from "obsidian"; import { get } from "svelte/store"; import { plugin } from "../store"; +import { assertFetchableUrl } from "../utility/assertFetchableUrl"; import { encodeUrlForRequest } from "../utility/encodeUrlForRequest"; import { enforceMaxPathLength } from "../utility/enforceMaxPathLength"; @@ -25,6 +26,23 @@ import { enforceMaxPathLength } from "../utility/enforceMaxPathLength"; export const DOWNLOAD_CHUNK_SIZE = 4 * 1024 * 1024; // 4 MiB per range request +// Total-bytes ceiling for a single download. The per-chunk bound above keeps +// peak memory flat, but without a TOTAL cap a malicious media server (the host of +// a feed's enclosure URL is attacker-controlled) can fill the disk: it can answer +// 200 with an enormous body, advertise an arbitrarily large Content-Range total, +// or - with an unknown total ("bytes 0-N/*") - return full-size 206 chunks forever +// so the append loop never terminates. 2 GiB clears any real podcast episode +// (long-form audio is ~hundreds of MB; even large video episodes fit) while +// turning the unbounded write into a bounded, recoverable failure. +export const MAX_DOWNLOAD_SIZE = 2 * 1024 * 1024 * 1024; // 2 GiB + +function tooLargeError(maxSize: number): Error { + const maxMb = Math.round(maxSize / (1024 * 1024)); + return new Error( + `Download exceeds the maximum allowed size (${maxMb} MB). Aborting.`, + ); +} + // Obsidian's DataAdapter — writeBinary/rename/remove/list, all used below — is // fully typed and public. Only `appendBinary` exists at runtime on the desktop and // mobile Capacitor adapters without appearing in the public typings, so we extend @@ -69,7 +87,9 @@ function readHeader( export async function probeAndFetchFirstChunk( url: string, chunkSize: number = DOWNLOAD_CHUNK_SIZE, + maxSize: number = MAX_DOWNLOAD_SIZE, ): Promise { + assertFetchableUrl(url); const encodedUrl = encodeUrlForRequest(url); const response = await requestUrl({ url: encodedUrl, @@ -104,6 +124,17 @@ export async function probeAndFetchFirstChunk( } } + // Reject an oversized download up front: a known total over the cap, or a 200 + // fallback whose whole body (requestUrl has already buffered it) is over the + // cap. The unknown-total 206 case can't be caught here and is bounded by the + // running-total check in writeStreamedFile instead. + if (totalSize !== null && totalSize > maxSize) { + throw tooLargeError(maxSize); + } + if (!supportsRange && response.arrayBuffer.byteLength > maxSize) { + throw tooLargeError(maxSize); + } + return { firstChunk: response.arrayBuffer, contentType, @@ -123,9 +154,15 @@ export async function writeStreamedFile( probe: RangeProbe, onProgress?: (written: number, total: number | null) => void, chunkSize: number = DOWNLOAD_CHUNK_SIZE, + maxSize: number = MAX_DOWNLOAD_SIZE, ): Promise { + assertFetchableUrl(url); const adapter = appendableAdapter(); + if (probe.firstChunk.byteLength > maxSize) { + throw tooLargeError(maxSize); + } + await adapter.writeBinary(destPath, probe.firstChunk); let written = probe.firstChunk.byteLength; onProgress?.(written, probe.totalSize); @@ -166,6 +203,14 @@ export async function writeStreamedFile( written += chunk.byteLength; onProgress?.(written, probe.totalSize); + // Hard ceiling on the total written. This is the only stop for a server + // that advertises an unknown total ("bytes 0-N/*") and returns full-size + // 206 chunks forever — without it the loop would append to disk until the + // disk fills. The caller's finally drops the (now over-cap) temp file. + if (written > maxSize) { + throw tooLargeError(maxSize); + } + // Unknown total: a short chunk means we hit EOF. if (probe.totalSize === null && chunk.byteLength < chunkSize) break; } diff --git a/src/downloadEpisode.test.ts b/src/downloadEpisode.test.ts index 887176e4..5b3181c3 100644 --- a/src/downloadEpisode.test.ts +++ b/src/downloadEpisode.test.ts @@ -1225,6 +1225,23 @@ describe("getEpisodeAudioBuffer (issue #107)", () => { expect(result.basename).toBe("recording"); expect(requestUrlMock).not.toHaveBeenCalled(); }); + + it.each([ + "file:///Users/victim/.ssh/id_rsa", + "http://169.254.169.254/latest/meta-data/", + "http://127.0.0.1:9200/_search", + ])( + "refuses to fetch a feed-controlled stream URL pointing at %s (SSRF/exfil guard)", + async (streamUrl) => { + const ssrf = episode({ + title: "Malicious Enclosure", + streamUrl, + }); + + await expect(getEpisodeAudioBuffer(ssrf)).rejects.toThrow(/Refusing/); + expect(requestUrlMock).not.toHaveBeenCalled(); + }, + ); }); describe("downloadEpisodeWithNotice (streaming range path)", () => { diff --git a/src/downloadEpisode.ts b/src/downloadEpisode.ts index 59b16c26..f5cad1f1 100644 --- a/src/downloadEpisode.ts +++ b/src/downloadEpisode.ts @@ -7,6 +7,7 @@ import { } from "./TemplateEngine"; import type { Episode, EpisodeMediaType } from "./types/Episode"; import type { LocalEpisode } from "./types/LocalEpisode"; +import { assertFetchableUrl } from "./utility/assertFetchableUrl"; import { encodeUrlForRequest } from "./utility/encodeUrlForRequest"; import { enforceMaxPathLength } from "./utility/enforceMaxPathLength"; import { ensureFolderExists } from "./utility/ensureFolderExists"; @@ -53,6 +54,7 @@ interface DownloadedFile { // and transcription's getEpisodeAudioBuffer still need the entire buffer at once. // The Download command streams instead — see downloadEpisodeToDisk. async function downloadFile(url: string): Promise { + assertFetchableUrl(url); const encodedUrl = encodeUrlForRequest(url); try { const response = await requestUrl({ url: encodedUrl, method: "GET" }); @@ -708,6 +710,7 @@ export async function downloadEpisode( } async function getFileExtension(url: string): Promise { + assertFetchableUrl(url); const encodedUrl = encodeUrlForRequest(url); const urlExtension = getUrlExtension(encodedUrl); if (urlExtension) return urlExtension; diff --git a/src/utility/assertFetchableUrl.test.ts b/src/utility/assertFetchableUrl.test.ts new file mode 100644 index 00000000..98f63d00 --- /dev/null +++ b/src/utility/assertFetchableUrl.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + assertFetchableUrl, + isFetchableUrl, + UnsafeFetchUrlError, +} from "./assertFetchableUrl"; + +describe("assertFetchableUrl", () => { + it("accepts ordinary public http(s) feed/enclosure URLs", () => { + expect(() => + assertFetchableUrl("https://pod.example.com/audio.mp3?token=abc"), + ).not.toThrow(); + expect(() => + assertFetchableUrl("http://cdn.example.org/ep/1.mp3"), + ).not.toThrow(); + // A public IP is fine. + expect(() => assertFetchableUrl("https://8.8.8.8/feed.xml")).not.toThrow(); + }); + + it("returns the parsed URL", () => { + const url = assertFetchableUrl("https://example.com/a.mp3"); + expect(url.hostname).toBe("example.com"); + }); + + it.each([ + "file:///Users/victim/.ssh/id_rsa", + "data:text/plain,hello", + "blob:https://example.com/uuid", + "ftp://example.com/file", + "javascript:alert(1)", + ])("rejects non-http(s) scheme %s", (url) => { + expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError); + }); + + it.each([ + "", + " ", + "not a url", + "//example.com/no-scheme", + ])("rejects empty/malformed url %s", (url) => { + expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError); + }); + + it.each([ + "http://localhost/feed.xml", + "http://LOCALHOST:8080/x", + "http://api.localhost/x", + "http://localhost./x", // absolute-FQDN form still resolves to loopback + "http://LOCALHOST./x", + "http://sub.localhost./x", + "http://127.0.0.1/x", + "http://127.1.2.3/x", + "http://10.0.0.5/x", + "http://172.16.0.1/x", + "http://172.31.255.255/x", + "http://192.168.1.5/x", + "http://169.254.169.254/latest/meta-data/", // cloud metadata + "http://0.0.0.0/x", + ])("blocks loopback/private/link-local host %s", (url) => { + expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError); + }); + + it.each([ + "http://2130706433/x", // 127.0.0.1 as integer + "http://0x7f000001/x", // 127.0.0.1 as hex + "http://0177.0.0.1/x", // 127.0.0.1 with octal leading octet + "http://0/x", // 0.0.0.0 + ])("blocks obfuscated IPv4 loopback %s", (url) => { + expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError); + }); + + it.each([ + "http://[::1]/x", // loopback + "http://[::]/x", // unspecified + "http://[fe80::1]/x", // link-local + "http://[fc00::1]/x", // unique-local + "http://[fd12:3456:789a::1]/x", // unique-local + "http://[::ffff:127.0.0.1]/x", // IPv4-mapped loopback + "http://[::ffff:169.254.169.254]/x", // IPv4-mapped metadata + "http://[::ffff:7f00:1]/x", // IPv4-mapped loopback in hex form + ])("blocks loopback/private IPv6 host %s", (url) => { + expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError); + }); + + it.each([ + "https://[2606:4700:4700::1111]/x", // public IPv6 (Cloudflare DNS) + "https://203.0.113.10/x", // public IPv4 + "https://pod.example.com./feed.xml", // legit absolute FQDN is not blocked + ])("allows public IPv6/IPv4/FQDN host %s", (url) => { + expect(() => assertFetchableUrl(url)).not.toThrow(); + }); + + it("exposes a non-throwing predicate", () => { + expect(isFetchableUrl("https://example.com/a.mp3")).toBe(true); + expect(isFetchableUrl("http://169.254.169.254/")).toBe(false); + expect(isFetchableUrl("file:///etc/passwd")).toBe(false); + }); +}); diff --git a/src/utility/assertFetchableUrl.ts b/src/utility/assertFetchableUrl.ts new file mode 100644 index 00000000..9dd62931 --- /dev/null +++ b/src/utility/assertFetchableUrl.ts @@ -0,0 +1,189 @@ +/** + * SSRF guard for every fetch whose URL comes from untrusted feed/URI content. + * + * Podcast enclosure URLs (``) and `obsidian://podnotes` + * deep-link URLs are fully attacker-controlled, yet they flow straight into + * Obsidian's `requestUrl`. Before issuing any such request we: + * - require an http(s) scheme, so a feed can't make the client read a local + * file (`file:`), inline a payload (`data:`/`blob:`), or hit a non-HTTP + * service (`ftp:`, ...); and + * - reject hosts that resolve to loopback / link-local / private / cloud-metadata + * ranges, so a feed can't turn the client into a blind SSRF proxy against + * 127.0.0.1, 169.254.169.254, or the user's intranet. + * + * This applies the same http(s)-only check the project already uses for the + * chapters URL (`isSupportedChaptersUrl` in fetchChapters.ts) and for feed-add + * (`PodcastQueryGrid.svelte`), and additionally filters the host, for the + * download/feed/URI fetch paths. + * + * Limitations (both inherent to validating a URL string ahead of an opaque + * `requestUrl`): + * - DNS rebinding: a public hostname that resolves to a private/loopback IP is + * allowed, because this checks the literal host, not the resolved address. + * - Redirects: `requestUrl` follows them, and an allowed http(s) host could 302 + * into a blocked range, which cannot be re-checked per hop here. + * The scheme allowlist (the `file:`/`data:` local-read/exfil vector) is unaffected + * by both. + */ +export class UnsafeFetchUrlError extends Error { + constructor(message: string) { + super(message); + this.name = "UnsafeFetchUrlError"; + } +} + +/** + * Validates that `rawUrl` is safe to fetch and returns the parsed URL. + * Throws {@link UnsafeFetchUrlError} for an unparseable URL, a non-http(s) + * scheme, or a host in a blocked (loopback/link-local/private/metadata) range. + */ +export function assertFetchableUrl(rawUrl: string): URL { + const trimmed = rawUrl?.trim() ?? ""; + if (!trimmed) { + throw new UnsafeFetchUrlError("Refusing to fetch an empty URL."); + } + + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new UnsafeFetchUrlError(`Refusing to fetch a malformed URL: ${rawUrl}`); + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new UnsafeFetchUrlError( + `Refusing to fetch a non-http(s) URL (${url.protocol}): ${rawUrl}`, + ); + } + + if (isBlockedHost(url.hostname)) { + throw new UnsafeFetchUrlError( + `Refusing to fetch a loopback/private/link-local address: ${url.hostname}`, + ); + } + + return url; +} + +/** Non-throwing companion to {@link assertFetchableUrl}. */ +export function isFetchableUrl(rawUrl: string): boolean { + try { + assertFetchableUrl(rawUrl); + return true; + } catch { + return false; + } +} + +function isBlockedHost(hostname: string): boolean { + // Drop a single trailing dot: "localhost." / "intranet.host." is the absolute + // FQDN form and resolves to the same address as the dotless name, so it must be + // classified the same. (The URL parser already strips it from IPv4 literals, + // but not from registrable names.) + const host = hostname.toLowerCase().replace(/\.$/, ""); + + // Names that always mean the local machine, regardless of DNS. + if (host === "localhost" || host.endsWith(".localhost")) return true; + + // IPv6 literals keep their brackets in URL.hostname (e.g. "[::1]"). + if (host.startsWith("[") && host.endsWith("]")) { + const groups = parseIpv6(host.slice(1, -1)); + return groups !== null && isBlockedIpv6(groups); + } + + // The WHATWG URL parser normalizes every IPv4 form (decimal, octal, hex, + // integer) to dotted-decimal, so this single check also covers obfuscated + // literals like http://2130706433/ or http://0x7f.1/. + const octets = parseIpv4(host); + if (octets) return isBlockedIpv4(octets); + + return false; +} + +function parseIpv4(host: string): number[] | null { + const parts = host.split("."); + if (parts.length !== 4) return null; + + const octets: number[] = []; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const value = Number.parseInt(part, 10); + if (value > 255) return null; + octets.push(value); + } + return octets; +} + +function isBlockedIpv4(octets: number[]): boolean { + const [a, b] = octets; + return ( + a === 0 || // 0.0.0.0/8 "this host" + a === 127 || // 127.0.0.0/8 loopback + a === 10 || // 10.0.0.0/8 private + (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private + (a === 192 && b === 168) || // 192.168.0.0/16 private + (a === 169 && b === 254) // 169.254.0.0/16 link-local (incl. cloud metadata) + ); +} + +/** Expand an IPv6 literal (no brackets) to its eight 16-bit groups. */ +function parseIpv6(input: string): number[] | null { + // Drop any zone id ("fe80::1%eth0"). + const zone = input.indexOf("%"); + let addr = zone === -1 ? input : input.slice(0, zone); + + // An embedded dotted-quad in the final group ("::ffff:127.0.0.1") becomes + // two hex groups so the rest of the parser only deals in hextets. + const lastColon = addr.lastIndexOf(":"); + const tail = addr.slice(lastColon + 1); + if (tail.includes(".")) { + const v4 = parseIpv4(tail); + if (!v4) return null; + const high = ((v4[0] << 8) | v4[1]).toString(16); + const low = ((v4[2] << 8) | v4[3]).toString(16); + addr = `${addr.slice(0, lastColon + 1)}${high}:${low}`; + } + + const halves = addr.split("::"); + if (halves.length > 2) return null; + + const left = toGroups(halves[0]); + const right = halves.length === 2 ? toGroups(halves[1]) : []; + if (!left || !right) return null; + + if (halves.length === 2) { + const missing = 8 - left.length - right.length; + if (missing < 1) return null; // "::" must stand for at least one zero group + return [...left, ...new Array(missing).fill(0), ...right]; + } + + return left.length === 8 ? left : null; +} + +function toGroups(segment: string): number[] | null { + if (segment === "") return []; + const groups: number[] = []; + for (const part of segment.split(":")) { + if (!/^[0-9a-f]{1,4}$/.test(part)) return null; + groups.push(Number.parseInt(part, 16)); + } + return groups; +} + +function isBlockedIpv6(g: number[]): boolean { + // fc00::/7 unique-local. + if ((g[0] & 0xfe00) === 0xfc00) return true; + // fe80::/10 link-local. + if ((g[0] & 0xffc0) === 0xfe80) return true; + + // IPv4-mapped ("::ffff:a.b.c.d") and IPv4-compatible ("::a.b.c.d", which also + // covers ::1 loopback and :: unspecified): fold the trailing 32 bits back into + // an IPv4 address and reuse the IPv4 ranges. + const firstFiveZero = g.slice(0, 5).every((part) => part === 0); + if (firstFiveZero && (g[5] === 0xffff || g[5] === 0)) { + const v4 = [g[6] >> 8, g[6] & 0xff, g[7] >> 8, g[7] & 0xff]; + return isBlockedIpv4(v4); + } + + return false; +}