diff --git a/apps/api/plane/bgtasks/copy_s3_object.py b/apps/api/plane/bgtasks/copy_s3_object.py index 742966a6fbb..f604f0e0f5a 100644 --- a/apps/api/plane/bgtasks/copy_s3_object.py +++ b/apps/api/plane/bgtasks/copy_s3_object.py @@ -18,6 +18,11 @@ from celery import shared_task from plane.utils.url import normalize_url_path +# (connect, read) timeout for the Live service call. `requests` has no default +# timeout, so without one a Live service that stalls after accepting the connection +# pins a Celery worker forever. The read budget is wide: conversion is genuinely slow. +LIVE_REQUEST_TIMEOUT = (5, 30) + def get_entity_id_field(entity_type, entity_id): entity_mapping = { @@ -77,7 +82,24 @@ def sync_with_external_service(entity_name, description_html): url = normalize_url_path(f"{live_url}/convert-document/") - response = requests.post(url, json=data, headers=None) + # The Live service authenticates this endpoint with a shared secret. + # Without the header the request is rejected as 401. + secret_key = settings.LIVE_SERVER_SECRET_KEY + if not secret_key: + log_exception( + Exception( + "LIVE_SERVER_SECRET_KEY is not configured; skipping document conversion " + "for duplication. Set it to the same value as the Live service." + ) + ) + return {} + + response = requests.post( + url, + json=data, + headers={"live-server-secret-key": secret_key}, + timeout=LIVE_REQUEST_TIMEOUT, + ) if response.status_code == 200: return response.json() except requests.RequestException as e: diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 25a212e7639..a85efc79eda 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -418,6 +418,10 @@ LIVE_URL = urljoin(LIVE_BASE_URL, LIVE_BASE_PATH) if LIVE_BASE_URL else None +# Shared secret for server-to-server calls into the Live service. Must match the +# Live container's LIVE_SERVER_SECRET_KEY. +LIVE_SERVER_SECRET_KEY = os.environ.get("LIVE_SERVER_SECRET_KEY") + # WEB URL WEB_URL = os.environ.get("WEB_URL") diff --git a/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_object_auth.py b/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_object_auth.py new file mode 100644 index 00000000000..2a6f6ff0fc6 --- /dev/null +++ b/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_object_auth.py @@ -0,0 +1,133 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +""" +Authentication of the API -> Live `/convert-document/` call. + +Live now gates the endpoint on the `live-server-secret-key` header, and this task is +its only caller: the header must actually be sent, and a missing key must fail loudly +rather than firing a request that can only 401. Pure unit tests — no DB, no network. +""" + +from unittest.mock import MagicMock, patch + +import requests +from django.test import override_settings + +from plane.bgtasks.copy_s3_object import LIVE_REQUEST_TIMEOUT, sync_with_external_service + +LIVE_URL = "http://live:3000/live/" +SECRET = "unit-test-live-secret" + + +@override_settings(LIVE_URL=LIVE_URL, LIVE_SERVER_SECRET_KEY=SECRET) +def test_sends_secret_key_header(): + """The shared secret must travel on the request, or Live returns 401.""" + response = MagicMock(status_code=200) + response.json.return_value = {"description_json": {}, "description_binary": "AA=="} + + with patch("plane.bgtasks.copy_s3_object.requests.post", return_value=response) as mock_post: + result = sync_with_external_service("PAGE", "

hello

") + + assert result == {"description_json": {}, "description_binary": "AA=="} + mock_post.assert_called_once() + + headers = mock_post.call_args.kwargs["headers"] + assert headers == {"live-server-secret-key": SECRET} + + +@override_settings(LIVE_URL=LIVE_URL, LIVE_SERVER_SECRET_KEY=SECRET) +def test_sends_bounded_timeout(): + """ + `requests` has no default timeout. Without one, a Live service that accepts the + connection and then stalls would pin a Celery worker indefinitely. + """ + response = MagicMock(status_code=200) + response.json.return_value = {} + + with patch("plane.bgtasks.copy_s3_object.requests.post", return_value=response) as mock_post: + sync_with_external_service("PAGE", "

hello

") + + timeout = mock_post.call_args.kwargs["timeout"] + assert timeout == LIVE_REQUEST_TIMEOUT + connect, read = timeout + assert 0 < connect <= 10 + assert 0 < read <= 60 + + +@override_settings(LIVE_URL=LIVE_URL, LIVE_SERVER_SECRET_KEY=SECRET) +def test_timeout_is_swallowed_not_raised(): + """A stalled Live service must degrade duplication, not fail the whole task.""" + with patch( + "plane.bgtasks.copy_s3_object.requests.post", + side_effect=requests.exceptions.ReadTimeout("timed out"), + ): + result = sync_with_external_service("PAGE", "

hello

") + + assert result == {} + + +@override_settings(LIVE_URL=LIVE_URL, LIVE_SERVER_SECRET_KEY=None) +def test_missing_secret_key_skips_request(): + """ + With no key configured the call could only ever 401, so it is not attempted. + Returning {} leaves `description_binary` untouched upstream (the caller guards + on `if external_data:`), which degrades duplication rather than corrupting it. + """ + with ( + patch("plane.bgtasks.copy_s3_object.requests.post") as mock_post, + patch("plane.bgtasks.copy_s3_object.log_exception") as mock_log, + ): + result = sync_with_external_service("PAGE", "

hello

") + + assert result == {} + mock_post.assert_not_called() + # The misconfiguration must be surfaced, not swallowed silently. + mock_log.assert_called_once() + + +@override_settings(LIVE_URL=LIVE_URL, LIVE_SERVER_SECRET_KEY="") +def test_empty_secret_key_treated_as_missing(): + """An empty string is a misconfiguration, not a valid credential.""" + with ( + patch("plane.bgtasks.copy_s3_object.requests.post") as mock_post, + patch("plane.bgtasks.copy_s3_object.log_exception"), + ): + result = sync_with_external_service("PAGE", "

hello

") + + assert result == {} + mock_post.assert_not_called() + + +@override_settings(LIVE_URL=None, LIVE_SERVER_SECRET_KEY=SECRET) +def test_no_live_url_short_circuits(): + """Deployments without a Live service must not attempt the call at all.""" + with patch("plane.bgtasks.copy_s3_object.requests.post") as mock_post: + result = sync_with_external_service("PAGE", "

hello

") + + assert result == {} + mock_post.assert_not_called() + + +@override_settings(LIVE_URL=LIVE_URL, LIVE_SERVER_SECRET_KEY=SECRET) +def test_non_200_returns_empty_dict(): + """A rejected call (e.g. a stale key on one side) must not raise.""" + with patch("plane.bgtasks.copy_s3_object.requests.post", return_value=MagicMock(status_code=401)): + result = sync_with_external_service("PAGE", "

hello

") + + assert result == {} + + +@override_settings(LIVE_URL=LIVE_URL, LIVE_SERVER_SECRET_KEY=SECRET) +def test_variant_depends_on_entity_name(): + """Guard the existing contract while changing the auth around it.""" + response = MagicMock(status_code=200) + response.json.return_value = {} + + with patch("plane.bgtasks.copy_s3_object.requests.post", return_value=response) as mock_post: + sync_with_external_service("PAGE", "

x

") + assert mock_post.call_args.kwargs["json"]["variant"] == "rich" + + sync_with_external_service("ISSUE", "

x

") + assert mock_post.call_args.kwargs["json"]["variant"] == "document" diff --git a/apps/live/src/controllers/document.controller.ts b/apps/live/src/controllers/document.controller.ts index 3a0282f90d4..25238f8857a 100644 --- a/apps/live/src/controllers/document.controller.ts +++ b/apps/live/src/controllers/document.controller.ts @@ -7,10 +7,11 @@ import type { Request, Response } from "express"; import { z } from "zod"; // helpers -import { Controller, Post } from "@plane/decorators"; +import { Controller, Middleware, Post } from "@plane/decorators"; import { convertHTMLDocumentToAllFormats } from "@plane/editor"; // logger import { logger } from "@plane/logger"; +import { requireSecretKey } from "@/lib/auth-middleware"; import type { TConvertDocumentRequestBody } from "@/types"; // Define the schema with more robust validation @@ -25,7 +26,14 @@ const convertDocumentSchema = z.object({ @Controller("/convert-document") export class DocumentController { + /** + * Server-to-server only: the sole caller is the API's `copy_s3_object` background + * task. It was previously reachable unauthenticated by anyone who could hit the + * Live service, making an expensive HTML -> Y.js conversion free compute for the + * internet. Callers must now present `live-server-secret-key`. + */ @Post("/") + @Middleware(requireSecretKey) async convertDocument(req: Request, res: Response) { try { // Validate request body diff --git a/apps/live/src/lib/pdf/node-renderers.tsx b/apps/live/src/lib/pdf/node-renderers.tsx index 003d21f552a..7c2f25909bb 100644 --- a/apps/live/src/lib/pdf/node-renderers.tsx +++ b/apps/live/src/lib/pdf/node-renderers.tsx @@ -8,6 +8,7 @@ import { Image, Link, Text, View } from "@react-pdf/renderer"; import type { Style } from "@react-pdf/types"; import type { ReactElement } from "react"; import { CORE_EXTENSIONS } from "@plane/editor"; +import { isSafeImageSrc } from "@/lib/url-security"; import { BACKGROUND_COLORS, EDITOR_BACKGROUND_COLORS, resolveColorForPdf, TEXT_COLORS } from "./colors"; import { CheckIcon, ClipboardIcon, DocumentIcon, GlobeIcon, LightbulbIcon, LinkIcon } from "./icons"; import { applyMarks } from "./mark-renderers"; @@ -272,6 +273,17 @@ export const nodeRenderers: NodeRendererRegistry = { ? { alignItems: "flex-end" as const } : { alignItems: "flex-start" as const }; + // SSRF guard: `src` comes from page content, and @react-pdf/image will fetch() + // any URL with a host — including internal Docker service names — or + // fs.readFile() a bare path. Anything we won't fetch renders as a placeholder. + if (!isSafeImageSrc(src)) { + return ( + + [Image unavailable] + + ); + } + return ( [Image: {assetId.slice(0, 8)}...] diff --git a/apps/live/src/lib/url-security.ts b/apps/live/src/lib/url-security.ts new file mode 100644 index 00000000000..c6cc30ff148 --- /dev/null +++ b/apps/live/src/lib/url-security.ts @@ -0,0 +1,212 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import net from "node:net"; + +/** + * SSRF guards for URLs the Live service may cause to be fetched. + * + * The PDF exporter hands TipTap `node.attrs.src` to `@react-pdf/image`, which + * fetch()es anything with a host — and Live shares a Docker network with the API, + * database, Redis, RabbitMQ and MinIO. A `startsWith("http")` check is no defence: + * `http://api:8000/` starts with "http" too. The destination is what must be judged. + */ + +/** Schemes we are willing to hand to the PDF image pipeline. */ +const ALLOWED_SCHEMES = new Set(["http:", "https:", "data:"]); + +/** + * Hostname suffixes that only ever resolve inside a private network. + * Compared against the lowercased hostname, with a leading dot to avoid + * matching a public registrable domain that merely ends in these letters. + */ +const BLOCKED_HOST_SUFFIXES = [".local", ".localhost", ".internal", ".home.arpa", ".lan"]; + +/** Bare hostnames that need no DNS to be dangerous. */ +const BLOCKED_HOST_EXACT = new Set(["localhost", "metadata", "metadata.google.internal"]); + +/** + * Returns true when an IPv4 literal falls in a range that must never be fetched. + * Ranges follow IANA special-purpose registries rather than a hand-rolled + * "private IP" list, so CGNAT and benchmarking space are covered too. + */ +const isBlockedIPv4 = (ip: string): boolean => { + const parts = ip.split(".").map((p) => Number(p)); + if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) { + // Not a canonical dotted quad — callers treat unparseable hosts as unsafe. + return true; + } + const [a, b, c] = parts as [number, number, number, number]; + + if (a === 0) return true; // 0.0.0.0/8 "this host on this network" + if (a === 10) return true; // 10.0.0.0/8 private + if (a === 127) return true; // 127.0.0.0/8 loopback + if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT + if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata) + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private + if (a === 192 && b === 168) return true; // 192.168.0.0/16 private + if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmarking + if (a >= 224) return true; // 224.0.0.0/4 multicast, 240.0.0.0/4 reserved, 255.255.255.255 + + // The remaining special-purpose blocks are /24s inside otherwise-public /16s, so + // they must be matched on the third octet — testing only the second would blackhole + // real public space (192.0.3.0/24, 198.51.x, 203.0.x) and break legitimate images. + if (a === 192 && b === 0 && c === 0) return true; // 192.0.0.0/24 IETF protocol assignments + if (a === 192 && b === 0 && c === 2) return true; // 192.0.2.0/24 TEST-NET-1 + if (a === 198 && b === 51 && c === 100) return true; // 198.51.100.0/24 TEST-NET-2 + if (a === 203 && b === 0 && c === 113) return true; // 203.0.113.0/24 TEST-NET-3 + + return false; +}; + +/** + * Expands an IPv6 literal to its eight 16-bit groups, or null if unparseable. + * Prefix matching alone is not enough: `::ffff:127.0.0.1` and its expanded twin + * `0:0:0:0:0:ffff:127.0.0.1` denote the same address, so anything comparing raw + * strings blocks one and fetches the other. + */ +const expandIPv6 = (addr: string): number[] | null => { + let head = addr; + let tail = ""; + // A trailing dotted quad occupies the last two groups. + const dotted = head.match(/(?:^|:)((?:\d{1,3}\.){3}\d{1,3})$/); + if (dotted?.[1]) { + const quad = dotted[1].split(".").map(Number); + if (quad.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null; + head = head.slice(0, head.length - dotted[1].length); + tail = `${((quad[0] << 8) | quad[1]).toString(16)}:${((quad[2] << 8) | quad[3]).toString(16)}`; + head = head.endsWith(":") && !head.endsWith("::") ? head.slice(0, -1) : head; + head = head === "" ? "::" : head; + head = head.endsWith("::") ? `${head}${tail}` : `${head}:${tail}`; + } + + const halves = head.split("::"); + if (halves.length > 2) return null; + const left = halves[0] ? halves[0].split(":") : []; + const right = halves.length === 2 && halves[1] ? halves[1].split(":") : []; + const fill = halves.length === 2 ? 8 - left.length - right.length : 0; + if (fill < 0 || (halves.length === 1 && left.length !== 8)) return null; + + const groups = [...left, ...Array(fill).fill("0"), ...right]; + if (groups.length !== 8) return null; + const out = groups.map((g) => (/^[0-9a-f]{1,4}$/.test(g) ? parseInt(g, 16) : NaN)); + return out.some(Number.isNaN) ? null : out; +}; + +/** Returns true when an IPv6 literal must never be fetched. */ +const isBlockedIPv6 = (ip: string): boolean => { + const addr = ip.toLowerCase().replace(/^\[|\]$/g, ""); + + // Judge on the expanded form so alternate spellings cannot slip past the + // prefix checks below. Unparseable literals are treated as unsafe. + const groups = expandIPv6(addr); + if (!groups) return true; + + // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d): judge the embedded v4. + const firstFiveZero = groups.slice(0, 5).every((g) => g === 0); + if (firstFiveZero && (groups[5] === 0xffff || (groups[5] === 0 && (groups[6] !== 0 || groups[7] !== 0)))) { + const v4 = [groups[6] >> 8, groups[6] & 0xff, groups[7] >> 8, groups[7] & 0xff].join("."); + return isBlockedIPv4(v4); + } + + const [g0, g1] = groups; + if (groups.every((g) => g === 0)) return true; // :: unspecified + if (firstFiveZero && groups[5] === 0 && groups[6] === 0 && groups[7] === 1) return true; // ::1 loopback + if ((g0 & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local + // fec0::/10 deprecated site-local. Kept in step with the Python guard's + // _BLOCKED_NETWORKS (apps/api/plane/utils/ip_address.py) — the two lists must not + // drift, or one service will block a range the other happily fetches. + if ((g0 & 0xffc0) === 0xfec0) return true; + if ((g0 & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local + if ((g0 & 0xff00) === 0xff00) return true; // ff00::/8 multicast + if (g0 === 0x64 && g1 === 0xff9b) return true; // 64:ff9b::/96 + 64:ff9b:1::/48 NAT64 + if (g0 === 0x2002) return true; // 6to4 — can wrap a private v4 + if (g0 === 0x2001 && g1 === 0) return true; // 2001::/32 Teredo + + return false; +}; + +/** + * Returns true when `host` is an IP literal we refuse to fetch, or a numeric/ + * obfuscated host form that is not a canonical address at all. Obfuscated encodings + * (`0x7f000001`, `2130706433`, `127.1`) are rejected outright — some HTTP clients + * expand them to loopback, and normalising every variant is a losing game. + */ +export const isBlockedHostLiteral = (host: string): boolean => { + const bare = host.replace(/^\[|\]$/g, ""); + + const ipVersion = net.isIP(bare); + if (ipVersion === 4) return isBlockedIPv4(bare); + if (ipVersion === 6) return isBlockedIPv6(bare); + + // Hex (0x…), octal-ish, decimal, or short-form dotted numbers — never a real host. + if (/^0x[0-9a-f]+$/i.test(bare)) return true; + if (/^[0-9]+$/.test(bare)) return true; + if (/^[0-9.]+$/.test(bare)) return true; + + return false; +}; + +/** + * Returns true when a hostname is safe enough to hand to the image fetcher. + * + * Single-label hostnames are refused: that is exactly the shape of a Docker Compose + * service name (`api`, `plane-db`, `plane-minio`), the primary escalation path here. + * Public image hosts always carry a dot. + */ +const isAllowedHostname = (hostname: string): boolean => { + const host = hostname.toLowerCase(); + + if (!host) return false; + if (BLOCKED_HOST_EXACT.has(host)) return false; + if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return false; + if (isBlockedHostLiteral(host)) return false; + // No dot => single-label => container/service name on the internal network. + if (!host.includes(".")) return false; + // A trailing dot ("api.") sidesteps the check above without adding a real label. + if (host.endsWith(".")) return false; + + return true; +}; + +/** + * Decides whether a TipTap image `src` may reach the PDF image pipeline. `data:` is + * allowed because the asset pipeline pre-fetches images server-side and inlines them, + * so nothing is fetched at render time. Not a complete http(s) defence: the fetch + * happens inside `@react-pdf/image`, so a host that passes here but resolves to a + * blocked address is a residual DNS-rebinding TOCTOU (SECUR-245 follow-up). + * TODO(SECUR-245): close it by pre-fetching raw image nodes, as imageComponent does. + */ +export const isSafeImageSrc = (src: string): boolean => { + if (!src) return false; + + const trimmed = src.trim(); + if (!trimmed) return false; + + // Reject control characters and whitespace, which URL parsers strip and + // which have historically been used to smuggle a scheme past naive checks. + // oxlint-disable-next-line no-control-regex -- intentional: these are exactly what we reject + if (/[\u0000-\u0020\u007F]/.test(trimmed)) return false; + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + // Relative paths and bare filesystem paths land here. `@react-pdf/image` + // would hand those to fs.readFile(), so they are refused. + return false; + } + + if (!ALLOWED_SCHEMES.has(parsed.protocol)) return false; + + // data: carries its payload inline; there is no host to judge. + if (parsed.protocol === "data:") return trimmed.toLowerCase().startsWith("data:image/"); + + // Credentials in an image URL are never legitimate and can confuse host parsing. + if (parsed.username || parsed.password) return false; + + return isAllowedHostname(parsed.hostname); +}; diff --git a/apps/live/tests/lib/url-security.test.ts b/apps/live/tests/lib/url-security.test.ts new file mode 100644 index 00000000000..41f06eb6107 --- /dev/null +++ b/apps/live/tests/lib/url-security.test.ts @@ -0,0 +1,244 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { describe, expect, it } from "vitest"; +import { isBlockedHostLiteral, isSafeImageSrc } from "@/lib/url-security"; + +describe("isSafeImageSrc", () => { + describe("internal Docker service names", () => { + // The escalation path that matters: every one of these starts with "http", which + // is why an imageComponent-style startsWith("http") guard does not close it. + it.each([ + "http://api:8000/api/workspaces/", + "http://plane-minio:9000/uploads/", + "http://plane-db:5432/", + "http://plane-redis:6379/", + "http://plane-mq:5672/", + "http://web:3000/", + "http://admin:3000/", + "http://space:3000/", + "http://live:3000/", + ])("rejects %s", (src) => { + expect(isSafeImageSrc(src)).toBe(false); + }); + + it("rejects a single-label host even over https", () => { + expect(isSafeImageSrc("https://api/")).toBe(false); + }); + + it("rejects a trailing-dot host that would otherwise look multi-label", () => { + expect(isSafeImageSrc("http://api./")).toBe(false); + }); + }); + + describe("cloud metadata and loopback", () => { + it.each([ + "http://169.254.169.254/latest/meta-data/", + "http://metadata.google.internal/computeMetadata/v1/", + "http://metadata/computeMetadata/v1/", + "http://localhost:8000/", + "http://127.0.0.1:8000/", + "http://127.1.2.3/", + "http://[::1]/", + "http://[::ffff:127.0.0.1]/", + ])("rejects %s", (src) => { + expect(isSafeImageSrc(src)).toBe(false); + }); + }); + + describe("private, CGNAT, link-local and reserved ranges", () => { + it.each([ + "http://10.0.0.5/", + "http://172.16.0.1/", + "http://172.31.255.254/", + "http://192.168.1.1/", + "http://100.64.0.1/", // CGNAT — missed by naive "private IP" lists + "http://100.127.255.255/", + "http://0.0.0.0/", + "http://224.0.0.1/", // multicast + "http://255.255.255.255/", + "http://198.18.0.1/", // benchmarking + "http://[fd00::1]/", // IPv6 unique local + "http://[fe80::1]/", // IPv6 link-local + "http://[ff02::1]/", // IPv6 multicast + "http://[fec0::1]/", // IPv6 deprecated site-local + "http://[2002:7f00:1::]/", // 6to4 wrapping 127.0.0.1 + "http://[2001::1]/", // Teredo + "http://[64:ff9b::7f00:1]/", // NAT64 wrapping 127.0.0.1 + "http://[64:ff9b:1::1]/", // NAT64 local-use prefix + ])("rejects %s", (src) => { + expect(isSafeImageSrc(src)).toBe(false); + }); + + it("allows a public IP just outside a blocked range", () => { + // 100.63.x is public; the CGNAT block starts at 100.64. + expect(isSafeImageSrc("http://100.63.0.1/")).toBe(true); + // 172.32.x is public; the private block ends at 172.31. + expect(isSafeImageSrc("http://172.32.0.1/")).toBe(true); + }); + + // These /24s sit inside otherwise-public /16s. Blocking the whole /16 would + // silently stop legitimate images from rendering, so the boundaries are pinned + // in both directions. + it("blocks the reserved /24s exactly", () => { + expect(isSafeImageSrc("http://192.0.0.1/")).toBe(false); // 192.0.0.0/24 IETF protocol assignments + expect(isSafeImageSrc("http://192.0.2.1/")).toBe(false); // 192.0.2.0/24 TEST-NET-1 + expect(isSafeImageSrc("http://198.51.100.1/")).toBe(false); // 198.51.100.0/24 TEST-NET-2 + expect(isSafeImageSrc("http://203.0.113.1/")).toBe(false); // 203.0.113.0/24 TEST-NET-3 + }); + + it("still allows the public space surrounding those /24s", () => { + expect(isSafeImageSrc("http://192.0.1.1/")).toBe(true); + expect(isSafeImageSrc("http://192.0.3.1/")).toBe(true); + expect(isSafeImageSrc("http://198.51.99.1/")).toBe(true); + expect(isSafeImageSrc("http://198.51.101.1/")).toBe(true); + expect(isSafeImageSrc("http://203.0.112.1/")).toBe(true); + expect(isSafeImageSrc("http://203.0.114.1/")).toBe(true); + }); + }); + + describe("obfuscated address encodings", () => { + // Not canonical addresses, but several HTTP clients expand them to loopback. + it.each([ + "http://2130706433/", // decimal 127.0.0.1 + "http://0x7f000001/", // hex 127.0.0.1 + "http://127.1/", // short-form 127.0.0.1 + "http://0/", // shorthand for 0.0.0.0 + ])("rejects %s", (src) => { + expect(isSafeImageSrc(src)).toBe(false); + }); + }); + + describe("scheme handling", () => { + it.each([ + "file:///etc/passwd", + "ftp://example.com/x.png", + "gopher://example.com/", + "javascript:alert(1)", + "vbscript:msgbox(1)", + "blob:https://example.com/abc", + ])("rejects %s", (src) => { + expect(isSafeImageSrc(src)).toBe(false); + }); + + it("rejects bare filesystem paths that would reach fs.readFile", () => { + // Secondary local-file-read path: @react-pdf/image would fs.readFile these. + expect(isSafeImageSrc("/etc/passwd")).toBe(false); + expect(isSafeImageSrc("./relative.png")).toBe(false); + expect(isSafeImageSrc("../../etc/hosts")).toBe(false); + }); + + it("allows image data URIs (the asset pipeline's own output)", () => { + expect(isSafeImageSrc("data:image/jpeg;base64,/9j/4AAQSkZJRg==")).toBe(true); + expect(isSafeImageSrc("data:image/png;base64,iVBORw0KGgo=")).toBe(true); + }); + + it("rejects non-image data URIs", () => { + expect(isSafeImageSrc("data:text/html,")).toBe(false); + expect(isSafeImageSrc("data:application/javascript,alert(1)")).toBe(false); + }); + }); + + describe("whitespace and control-character smuggling", () => { + // URL parsers strip these, so a check performed before stripping can be walked + // straight past — a well-known class of URL-validation bypass. + it.each([ + "\thttp://api:8000/", + "\nhttp://api:8000/", + "\rhttp://api:8000/", + " http://127.0.0.1/", + "http://api\t:8000/", + "\u0000http://api:8000/", + "\u00A0http://api:8000/", // non-breaking space + "\uFEFFhttp://api:8000/", // BOM + ])("rejects %j", (src) => { + expect(isSafeImageSrc(src)).toBe(false); + }); + }); + + describe("embedded credentials", () => { + it("rejects URLs carrying credentials", () => { + expect(isSafeImageSrc("http://user:pass@images.example.com/a.png")).toBe(false); + // Credentials can also be used to make the real host hard to read. + expect(isSafeImageSrc("http://images.example.com@127.0.0.1/a.png")).toBe(false); + }); + }); + + describe("internal-only hostname suffixes", () => { + it.each([ + "http://printer.local/x.png", + "http://app.localhost/x.png", + "http://svc.internal/x.png", + "http://box.lan/x.png", + "http://thing.home.arpa/x.png", + ])("rejects %s", (src) => { + expect(isSafeImageSrc(src)).toBe(false); + }); + }); + + describe("legitimate images still render", () => { + it.each([ + "https://images.example.com/photo.png", + "http://cdn.example.org/a/b/c.jpg", + "https://user-images.githubusercontent.com/1/2.png", + "https://example.co.uk/img.webp", + "https://sub.domain.example.com:8443/img.png", + ])("allows %s", (src) => { + expect(isSafeImageSrc(src)).toBe(true); + }); + }); + + describe("empty and malformed input", () => { + it.each(["", " ", "not a url", "http://", "://example.com"])("rejects %j", (src) => { + expect(isSafeImageSrc(src)).toBe(false); + }); + }); +}); + +describe("isBlockedHostLiteral", () => { + it("classifies canonical IPv4 literals", () => { + expect(isBlockedHostLiteral("127.0.0.1")).toBe(true); + expect(isBlockedHostLiteral("10.1.2.3")).toBe(true); + expect(isBlockedHostLiteral("8.8.8.8")).toBe(false); + expect(isBlockedHostLiteral("1.1.1.1")).toBe(false); + }); + + it("classifies IPv6 literals with and without brackets", () => { + expect(isBlockedHostLiteral("::1")).toBe(true); + expect(isBlockedHostLiteral("[::1]")).toBe(true); + expect(isBlockedHostLiteral("2606:4700:4700::1111")).toBe(false); + }); + + it("blocks alternate spellings of the same IPv6 address", () => { + // The address is judged on its expanded form, so a non-canonical spelling + // cannot slip past a prefix check. Each of these is ::ffff:127.0.0.1. + expect(isBlockedHostLiteral("::ffff:127.0.0.1")).toBe(true); + expect(isBlockedHostLiteral("::ffff:7f00:1")).toBe(true); + expect(isBlockedHostLiteral("0:0::ffff:7f00:1")).toBe(true); + expect(isBlockedHostLiteral("0:0:0:0:0:ffff:127.0.0.1")).toBe(true); + expect(isBlockedHostLiteral("0000:0000:0000:0000:0000:ffff:7f00:0001")).toBe(true); + // ...and of ::1 and the cloud metadata address. + expect(isBlockedHostLiteral("0:0:0:0:0:0:0:1")).toBe(true); + expect(isBlockedHostLiteral("0:0:0:0:0:ffff:a9fe:a9fe")).toBe(true); + // Expanded forms of the range checks must hold too. + expect(isBlockedHostLiteral("fe80:0:0:0:0:0:0:1")).toBe(true); + expect(isBlockedHostLiteral("fc00:0:0:0:0:0:0:1")).toBe(true); + // Public addresses stay reachable in either spelling. + expect(isBlockedHostLiteral("2001:4860:4860:0:0:0:0:8888")).toBe(false); + expect(isBlockedHostLiteral("2001:4860:4860::8888")).toBe(false); + }); + + it("rejects malformed IPv6 URLs (caught at URL parsing, before the literal check)", () => { + expect(isSafeImageSrc("http://[::ffff:999.1.1.1]/x.png")).toBe(false); + expect(isSafeImageSrc("http://[1:2:3:4:5:6:7:8:9]/x.png")).toBe(false); + expect(isSafeImageSrc("http://[::1::2]/x.png")).toBe(false); + }); + + it("treats real hostnames as non-literals", () => { + expect(isBlockedHostLiteral("example.com")).toBe(false); + expect(isBlockedHostLiteral("api")).toBe(false); + }); +});