From bfdc65469900240b9227d076eadc96993903cb48 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Thu, 27 Aug 2026 10:52:06 +0530 Subject: [PATCH] fix(live): close redirect-follow gap in PDF image SSRF guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isSafeImageSrc only validated the URL handed to , before render. The actual fetch happens inside @react-pdf/image's fetchRemoteFile, which follows redirects by default and never re-consults the guard on the redirect target — so a URL on an ordinary public host could 302 to an internal address (cloud metadata, a Docker service name, loopback) and be fetched anyway, no DNS control required. Close it the same way imageComponent asset images are already handled: pre-fetch image-node srcs before rendering starts, instead of handing a raw URL to the renderer. Added fetchImageSrcSafely, which fetches with redirect: "manual" and re-validates every hop against isSafeImageSrc, capped at 5 redirects. pdf-export.service.ts now extracts external image srcs alongside asset ids and resolves both into the same data-URI map before the synchronous render pass runs; node-renderers.tsx's image renderer looks up that pre-resolved value instead of taking the raw src. Also switched the pre-existing per-asset URL resolution loop in processImages to Promise.all, since it was already independent per asset and needed to satisfy the same lint rule the new redirect-following code does (which, unlike that loop, is genuinely sequential by nature). Documented the residual DNS-rebinding TOCTOU that remains on the final, non-redirect hop: isSafeImageSrc judges a hostname once, and the actual fetch resolves DNS again independently, so a name that changes address between those two lookups is still unguarded. Re-validating every redirect hop closes the far more easily exploited "one crafted HTTP response" gap; it does not add DNS pinning. Co-authored-by: Plane AI --- apps/live/src/lib/pdf/node-renderers.tsx | 23 ++- apps/live/src/lib/url-security.ts | 84 ++++++++- .../services/pdf-export/pdf-export.service.ts | 160 ++++++++++++++++-- apps/live/tests/lib/pdf/pdf-rendering.test.ts | 75 ++++++++ apps/live/tests/lib/url-security.test.ts | 140 ++++++++++++++- 5 files changed, 453 insertions(+), 29 deletions(-) diff --git a/apps/live/src/lib/pdf/node-renderers.tsx b/apps/live/src/lib/pdf/node-renderers.tsx index 7c2f25909b..405be472c7 100644 --- a/apps/live/src/lib/pdf/node-renderers.tsx +++ b/apps/live/src/lib/pdf/node-renderers.tsx @@ -273,10 +273,23 @@ 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)) { + // SSRF guard: `src` comes from page content. It is never handed to @react-pdf/image + // directly — that fetch() follows redirects and would never re-consult a src + // validator on the redirect target, so a URL on an ordinary public host could + // 302 to an internal address and be fetched anyway. Instead, pdf-export.service.ts + // pre-fetches every `image`-node src through the redirect-safe path + // (fetchImageSrcSafely, see @/lib/url-security) before rendering starts, the same + // way `imageComponent` assets are pre-fetched below, so this render pass stays + // synchronous and only ever looks up an already-resolved value. + let resolvedSrc: string | null = null; + if (ctx.metadata?.resolvedImageUrls && ctx.metadata.resolvedImageUrls[src]) { + resolvedSrc = ctx.metadata.resolvedImageUrls[src]; + } else if (src.startsWith("data:") && isSafeImageSrc(src)) { + // data: carries its payload inline — nothing to fetch, so no pre-fetch entry exists. + resolvedSrc = src; + } + + if (!resolvedSrc) { return ( [Image unavailable] @@ -287,7 +300,7 @@ export const nodeRenderers: NodeRendererRegistry = { return ( diff --git a/apps/live/src/lib/url-security.ts b/apps/live/src/lib/url-security.ts index c6cc30ff14..75b195258c 100644 --- a/apps/live/src/lib/url-security.ts +++ b/apps/live/src/lib/url-security.ts @@ -175,10 +175,16 @@ const isAllowedHostname = (hostname: string): boolean => { /** * 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. + * so nothing is fetched at render time. + * + * This is a hostname/IP check on a single URL, not a complete defence by itself: + * a host that passes here can still resolve to a blocked address by the time the + * real fetch happens (DNS-rebinding TOCTOU — the name is looked up once here, and + * again, independently, whenever the URL is actually fetched). `fetchImageSrcSafely` + * below re-runs this same check on every redirect hop, which closes the far more + * easily exploited gap — a plain 3xx response pointed at an internal address — but + * does not re-resolve DNS between validating and fetching the final, non-redirect + * response, so the rebinding window still exists on that last hop. */ export const isSafeImageSrc = (src: string): boolean => { if (!src) return false; @@ -210,3 +216,73 @@ export const isSafeImageSrc = (src: string): boolean => { return isAllowedHostname(parsed.hostname); }; + +/** Redirect hops `fetchImageSrcSafely` will follow before giving up on a source. */ +const MAX_IMAGE_FETCH_REDIRECTS = 5; + +/** + * Fetches an http(s) image URL the way `isSafeImageSrc` alone cannot guard: real + * fetch libraries (`@react-pdf/image`'s `fetchRemoteFile`, plain `fetch()`) follow + * redirects by default and never re-consult a src validator on the redirect target. + * That means a URL on an ordinary public host can 302 to an internal address — + * cloud metadata, a Docker service name, loopback — and sail straight through a + * check that only ever looked at the URL it was first handed. + * + * This fetches with `redirect: "manual"`, and on every 3xx response resolves the + * `Location` header against the current URL and re-validates it with + * `isSafeImageSrc` before following it, capped at `MAX_IMAGE_FETCH_REDIRECTS` hops. + * Any failure — the initial URL, a redirect target, or the hop count failing + * validation, a network error, or an unreadable body — resolves to `null`, which + * callers treat exactly like `isSafeImageSrc` returning `false`: render a + * placeholder, never hand the raw URL to the PDF image pipeline. + * + * Returns the fetched body as a `Buffer` on success, so callers can feed it through + * the same image-processing path used for pre-fetched asset-store images. + */ +export const fetchImageSrcSafely = async (uri: string): Promise => { + let current = uri; + + for (let hop = 0; hop <= MAX_IMAGE_FETCH_REDIRECTS; hop++) { + if (!isSafeImageSrc(current)) return null; + + let response: Response; + try { + // Each hop's request target comes from the previous hop's response, so these + // fetches cannot be parallelized — they must run one at a time, in order. + // oxlint-disable-next-line no-await-in-loop -- intentional, see above + response = await fetch(current, { redirect: "manual" }); + } catch { + return null; + } + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location) return null; + + let next: URL; + try { + next = new URL(location, current); + } catch { + // Unparseable Location header — nothing safe to follow. + return null; + } + current = next.toString(); + continue; // Re-validated by isSafeImageSrc at the top of the next iteration. + } + + if (!response.ok) return null; + + try { + // Terminal (non-redirect) response, reached after at most MAX_IMAGE_FETCH_REDIRECTS + // sequential hops above — there is nothing left here to run in parallel with. + // oxlint-disable-next-line no-await-in-loop -- intentional, see above + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + } catch { + return null; + } + } + + // Exhausted MAX_IMAGE_FETCH_REDIRECTS hops without reaching a terminal response. + return null; +}; diff --git a/apps/live/src/services/pdf-export/pdf-export.service.ts b/apps/live/src/services/pdf-export/pdf-export.service.ts index e9c67fc36a..9702bf6dc2 100644 --- a/apps/live/src/services/pdf-export/pdf-export.service.ts +++ b/apps/live/src/services/pdf-export/pdf-export.service.ts @@ -9,6 +9,7 @@ import sharp from "sharp"; import { getAllDocumentFormatsFromDocumentEditorBinaryData } from "@plane/editor/lib"; import type { PDFExportMetadata, TipTapDocument } from "@/lib/pdf"; import { renderPlaneDocToPdfBuffer } from "@/lib/pdf"; +import { fetchImageSrcSafely } from "@/lib/url-security"; import { getPageService } from "@/services/page/handler"; import type { TDocumentTypes } from "@/types"; import { @@ -32,6 +33,21 @@ type TipTapNode = { content?: TipTapNode[]; }; +/** + * Normalizes a fetched image buffer into the JPEG data URI the PDF renderer expects — + * downscaled to IMAGE_MAX_DIMENSION and flattened onto white — the same way for every + * image source, whether it came from the asset store or an external URL. + */ +const toPdfImageDataUri = async (buffer: Buffer): Promise => { + const processed = await sharp(buffer) + .rotate() + .flatten({ background: { r: 255, g: 255, b: 255 } }) + .resize(IMAGE_MAX_DIMENSION, IMAGE_MAX_DIMENSION, { fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: 85 }) + .toBuffer(); + return `data:image/jpeg;base64,${processed.toString("base64")}`; +}; + /** * PDF Export Service */ @@ -68,6 +84,37 @@ export class PdfExportService extends Effect.Service()("PdfExp return [...new Set(assetIds)]; }, + /** + * Extracts external (http/https) image URLs from raw `image` nodes — arbitrary + * URLs pasted into page content, as opposed to Plane's own uploaded assets. + * These are not covered by `extractImageAssetIds`: an external URL is exactly + * what that function's `!src.startsWith("http")` filter excludes, since it only + * collects internal asset ids. Left unresolved, that URL would otherwise reach + * `` unfetched and be handed straight to `@react-pdf/image` at + * render time, which is the redirect-follow SSRF gap `processExternalImages` + * closes by pre-fetching these the same way `processImages` pre-fetches assets. + */ + extractExternalImageSrcs: (doc: TipTapNode): string[] => { + const srcs: string[] = []; + + const traverse = (node: TipTapNode) => { + if (node.type === "image" && node.attrs?.src) { + const src = node.attrs.src as string; + if (src && (src.startsWith("http://") || src.startsWith("https://"))) { + srcs.push(src); + } + } + if (node.content) { + for (const child of node.content) { + traverse(child); + } + } + }; + + traverse(doc); + return [...new Set(srcs)]; + }, + /** * Fetches page content (description binary) and parses it */ @@ -164,12 +211,13 @@ export class PdfExportService extends Effect.Service()("PdfExp // Resolve URLs first const resolvedUrlMap = yield* tryAsync( async () => { - const urlMap = new Map(); - for (const assetId of assetIds) { - const url = await pageService.resolveImageAssetUrl?.(workspaceSlug, assetId, projectId); - if (url) urlMap.set(assetId, url); - } - return urlMap; + const entries = await Promise.all( + assetIds.map(async (assetId) => { + const url = await pageService.resolveImageAssetUrl?.(workspaceSlug, assetId, projectId); + return url ? ([assetId, url] as const) : null; + }) + ); + return new Map(entries.filter((entry): entry is readonly [string, string] => entry !== null)); }, () => new Map() ).pipe(recoverWithDefault(new Map())); @@ -210,14 +258,8 @@ export class PdfExportService extends Effect.Service()("PdfExp }) ); - const processedBuffer = yield* tryAsync( - () => - sharp(Buffer.from(arrayBuffer)) - .rotate() - .flatten({ background: { r: 255, g: 255, b: 255 } }) - .resize(IMAGE_MAX_DIMENSION, IMAGE_MAX_DIMENSION, { fit: "inside", withoutEnlargement: true }) - .jpeg({ quality: 85 }) - .toBuffer(), + const dataUri = yield* tryAsync( + () => toPdfImageDataUri(Buffer.from(arrayBuffer)), (cause) => new PdfImageProcessingError({ message: "Failed to process image", @@ -226,8 +268,7 @@ export class PdfExportService extends Effect.Service()("PdfExp }) ); - const base64 = processedBuffer.toString("base64"); - return [assetId, `data:image/jpeg;base64,${base64}`] as const; + return [assetId, dataUri] as const; }).pipe( withTimeoutAndRetry(`process image ${assetId}`, { timeoutMs: IMAGE_TIMEOUT_MS, @@ -252,6 +293,81 @@ export class PdfExportService extends Effect.Service()("PdfExp return Object.fromEntries(filtered); }), + /** + * Pre-fetches raw `image`-node URLs through the redirect-safe fetch path + * (`fetchImageSrcSafely`) and resolves each into a data URI, exactly like + * `processImages` does for asset-store images. Keyed by the source URL itself, + * so `node-renderers.tsx` can look a src up the same way it looks up an asset id. + * Failures (unsafe src, unsafe redirect target, network error) resolve to + * nothing for that key — the renderer falls back to its placeholder, it never + * sees the raw URL. + */ + processExternalImages: (srcs: string[], requestId: string): Effect.Effect> => + Effect.gen(function* () { + if (srcs.length === 0) { + return {}; + } + + yield* Effect.logDebug("PDF_EXPORT: Processing external image sources", { + requestId, + count: srcs.length, + }); + + const processSingleExternalImage = (src: string) => + Effect.gen(function* () { + const buffer = yield* tryAsync( + () => fetchImageSrcSafely(src), + (cause) => + new PdfImageProcessingError({ + message: "Failed to fetch external image", + assetId: src, + cause, + }) + ); + + if (!buffer) { + return yield* Effect.fail( + new PdfImageProcessingError({ + message: "External image failed SSRF validation or fetch", + assetId: src, + }) + ); + } + + const dataUri = yield* tryAsync( + () => toPdfImageDataUri(buffer), + (cause) => + new PdfImageProcessingError({ + message: "Failed to process external image", + assetId: src, + cause, + }) + ); + + return [src, dataUri] as const; + }).pipe( + withTimeoutAndRetry(`process external image ${src}`, { + timeoutMs: IMAGE_TIMEOUT_MS, + maxRetries: 1, + }), + Effect.tapError((error) => + Effect.logWarning("PDF_EXPORT: External image processing failed", { + requestId, + src, + error, + }) + ), + Effect.catchAll(() => Effect.succeed(null as readonly [string, string] | null)) + ); + + const pairs = yield* Effect.forEach(srcs, processSingleExternalImage, { + concurrency: IMAGE_CONCURRENCY, + }); + + const filtered = pairs.filter((p): p is readonly [string, string] => p !== null); + return Object.fromEntries(filtered); + }), + /** * Renders document to PDF buffer */ @@ -325,8 +441,9 @@ export const exportToPdf = ( // Fetch content const content = yield* service.fetchPageContent(pageService, pageId, requestId); - // Extract image asset IDs + // Extract image asset IDs and raw external image URLs const imageAssetIds = service.extractImageAssetIds(content.contentJSON as TipTapNode); + const externalImageSrcs = service.extractExternalImageSrcs(content.contentJSON as TipTapNode); // Fetch user mentions let metadata = yield* service.fetchUserMentions(pageService, pageId, requestId); @@ -340,7 +457,14 @@ export const exportToPdf = ( imageAssetIds, requestId ); - metadata = { ...metadata, resolvedImageUrls: resolvedImages }; + metadata = { ...metadata, resolvedImageUrls: { ...metadata.resolvedImageUrls, ...resolvedImages } }; + } + + // Pre-fetch raw `image`-node URLs through the redirect-safe fetch path, keyed by + // the URL itself, merged into the same map processImages populates above. + if (!noAssets && externalImageSrcs.length > 0) { + const resolvedExternalImages = yield* service.processExternalImages(externalImageSrcs, requestId); + metadata = { ...metadata, resolvedImageUrls: { ...metadata.resolvedImageUrls, ...resolvedExternalImages } }; } yield* Effect.logDebug("PDF_EXPORT: Metadata prepared", { diff --git a/apps/live/tests/lib/pdf/pdf-rendering.test.ts b/apps/live/tests/lib/pdf/pdf-rendering.test.ts index 507c6f900a..f7b95f683d 100644 --- a/apps/live/tests/lib/pdf/pdf-rendering.test.ts +++ b/apps/live/tests/lib/pdf/pdf-rendering.test.ts @@ -6,11 +6,22 @@ import { describe, it, expect } from "vitest"; import { PDFParse } from "pdf-parse"; +import sharp from "sharp"; import { renderPlaneDocToPdfBuffer } from "@/lib/pdf"; import type { TipTapDocument, PDFExportMetadata } from "@/lib/pdf"; const PDF_HEADER = "%PDF-"; +/** A tiny valid JPEG data URI, standing in for a pre-fetched/resolved image src. */ +async function tinyJpegDataUri(): Promise { + const buffer = await sharp({ + create: { width: 2, height: 2, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + return `data:image/jpeg;base64,${buffer.toString("base64")}`; +} + /** * Helper to extract text content from a PDF buffer */ @@ -729,4 +740,68 @@ describe("PDF Rendering Integration", () => { expect(text).toContain("Text after image"); }); }); + + describe("image node SSRF pre-resolution", () => { + // The `image` renderer never fetches its own src or hands the raw URL to + // @react-pdf/image at render time — pdf-export.service.ts pre-fetches every + // external `image`-node src (through the redirect-safe path) before rendering + // starts and hands the result in via metadata.resolvedImageUrls, the same way + // it already does for `imageComponent` assets. These tests exercise that + // renderer-level contract directly, without going through the fetch pipeline. + + it("renders the pre-resolved data URI when metadata carries a resolved entry for the src", async () => { + const src = "https://images.example.com/photo.png"; + const doc: TipTapDocument = { + type: "doc", + content: [{ type: "image", attrs: { src } }], + }; + const metadata: PDFExportMetadata = { resolvedImageUrls: { [src]: await tinyJpegDataUri() } }; + + const buffer = await renderPlaneDocToPdfBuffer(doc, { metadata }); + + expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER); + }); + + it("renders the placeholder, never the raw src, when the src has no pre-resolved entry", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [{ type: "image", attrs: { src: "https://images.example.com/never-fetched.png" } }], + }; + + // No metadata at all: the renderer must fall back to the placeholder rather + // than passing the raw external URL straight to . + const buffer = await renderPlaneDocToPdfBuffer(doc); + const text = await extractPdfText(buffer); + + expect(text).toContain("Image unavailable"); + }); + + it("renders the placeholder for a src that failed pre-resolution (e.g. blocked by SSRF checks)", async () => { + const src = "http://169.254.169.254/latest/meta-data/"; + const doc: TipTapDocument = { + type: "doc", + content: [{ type: "image", attrs: { src } }], + }; + // A failed pre-fetch (unsafe src, unsafe redirect target, or network error) + // simply omits the key from resolvedImageUrls — it never carries a fallback + // to the raw src. + const metadata: PDFExportMetadata = { resolvedImageUrls: {} }; + + const buffer = await renderPlaneDocToPdfBuffer(doc, { metadata }); + const text = await extractPdfText(buffer); + + expect(text).toContain("Image unavailable"); + }); + + it("still renders inline data: URIs directly, with no pre-fetch entry required", async () => { + const doc: TipTapDocument = { + type: "doc", + content: [{ type: "image", attrs: { src: await tinyJpegDataUri() } }], + }; + + const buffer = await renderPlaneDocToPdfBuffer(doc); + + expect(buffer.toString("ascii", 0, 5)).toBe(PDF_HEADER); + }); + }); }); diff --git a/apps/live/tests/lib/url-security.test.ts b/apps/live/tests/lib/url-security.test.ts index 41f06eb610..6620326cd9 100644 --- a/apps/live/tests/lib/url-security.test.ts +++ b/apps/live/tests/lib/url-security.test.ts @@ -4,8 +4,8 @@ * See the LICENSE file for details. */ -import { describe, expect, it } from "vitest"; -import { isBlockedHostLiteral, isSafeImageSrc } from "@/lib/url-security"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchImageSrcSafely, isBlockedHostLiteral, isSafeImageSrc } from "@/lib/url-security"; describe("isSafeImageSrc", () => { describe("internal Docker service names", () => { @@ -242,3 +242,139 @@ describe("isBlockedHostLiteral", () => { expect(isBlockedHostLiteral("api")).toBe(false); }); }); + +const redirectTo = (location?: string) => + new Response(null, { status: 302, headers: location ? { Location: location } : undefined }); + +const ok = (body = "image-bytes") => new Response(body, { status: 200 }); + +describe("fetchImageSrcSafely", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("refuses the initial URL without fetching when it fails isSafeImageSrc", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const result = await fetchImageSrcSafely("http://169.254.169.254/latest/meta-data/"); + + expect(result).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("refuses a safe host that redirects to a blocked address (link-local metadata)", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(redirectTo("http://169.254.169.254/latest/meta-data/")); + vi.stubGlobal("fetch", fetchMock); + + const result = await fetchImageSrcSafely("https://images.example.com/a.png"); + + expect(result).toBeNull(); + // The redirect target fails isSafeImageSrc before it is ever fetched. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("refuses a safe host that redirects to a loopback address", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(redirectTo("http://127.0.0.1:8000/")); + vi.stubGlobal("fetch", fetchMock); + + expect(await fetchImageSrcSafely("https://images.example.com/a.png")).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("refuses a safe host that redirects to an RFC1918 private address", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(redirectTo("http://10.0.0.5/internal")); + vi.stubGlobal("fetch", fetchMock); + + expect(await fetchImageSrcSafely("https://images.example.com/a.png")).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("refuses a safe host that redirects to a Docker service name", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(redirectTo("http://api:8000/api/workspaces/")); + vi.stubGlobal("fetch", fetchMock); + + expect(await fetchImageSrcSafely("https://images.example.com/a.png")).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("follows a redirect from one safe host to another and returns the fetched body", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(redirectTo("https://cdn.example.org/final.png")) + .mockResolvedValueOnce(ok("final-bytes")); + vi.stubGlobal("fetch", fetchMock); + + const result = await fetchImageSrcSafely("https://images.example.com/a.png"); + + expect(result).not.toBeNull(); + expect(result?.toString("utf-8")).toBe("final-bytes"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith(1, "https://images.example.com/a.png", { redirect: "manual" }); + expect(fetchMock).toHaveBeenNthCalledWith(2, "https://cdn.example.org/final.png", { redirect: "manual" }); + }); + + it("resolves a relative Location header against the current URL before re-validating it", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(redirectTo("/final.png")).mockResolvedValueOnce(ok("final-bytes")); + vi.stubGlobal("fetch", fetchMock); + + const result = await fetchImageSrcSafely("https://images.example.com/a.png"); + + expect(result?.toString("utf-8")).toBe("final-bytes"); + expect(fetchMock).toHaveBeenNthCalledWith(2, "https://images.example.com/final.png", { redirect: "manual" }); + }); + + it("refuses a redirect chain that exceeds the cap", async () => { + let hop = 0; + const fetchMock = vi.fn().mockImplementation(() => { + hop += 1; + return Promise.resolve(redirectTo(`https://images.example.com/hop-${hop}`)); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await fetchImageSrcSafely("https://images.example.com/a.png"); + + expect(result).toBeNull(); + // 1 initial request plus 5 allowed redirects = 6 requests; the 6th response is + // itself a redirect and is never followed. + expect(fetchMock).toHaveBeenCalledTimes(6); + }); + + it("refuses a redirect with no Location header", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(redirectTo()); + vi.stubGlobal("fetch", fetchMock); + + expect(await fetchImageSrcSafely("https://images.example.com/a.png")).toBeNull(); + }); + + it("refuses an unparseable Location header", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(redirectTo("http://[not-a-valid-host")); + vi.stubGlobal("fetch", fetchMock); + + expect(await fetchImageSrcSafely("https://images.example.com/a.png")).toBeNull(); + }); + + it("refuses a non-ok, non-redirect response", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 404 })); + vi.stubGlobal("fetch", fetchMock); + + expect(await fetchImageSrcSafely("https://images.example.com/a.png")).toBeNull(); + }); + + it("refuses when the fetch itself throws", async () => { + const fetchMock = vi.fn().mockRejectedValueOnce(new Error("network error")); + vi.stubGlobal("fetch", fetchMock); + + expect(await fetchImageSrcSafely("https://images.example.com/a.png")).toBeNull(); + }); + + it("returns the body untouched on a direct 200 with no redirects", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(ok("direct-bytes")); + vi.stubGlobal("fetch", fetchMock); + + const result = await fetchImageSrcSafely("https://images.example.com/a.png"); + + expect(result?.toString("utf-8")).toBe("direct-bytes"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +});