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