fix(live): close redirect-follow gap in PDF image SSRF guard

isSafeImageSrc only validated the URL handed to <Image src>, 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 <noreply@plane.so>
This commit is contained in:
Manish Gupta
2026-08-27 10:52:06 +05:30
parent cc71787d86
commit bfdc654699
5 changed files with 453 additions and 29 deletions

View File

@@ -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 (
<View key={ctx.getKey()} style={[pdfStyles.imagePlaceholder, alignmentStyle]}>
<Text style={pdfStyles.imagePlaceholderText}>[Image unavailable]</Text>
@@ -287,7 +300,7 @@ export const nodeRenderers: NodeRendererRegistry = {
return (
<View key={ctx.getKey()} style={[{ width: "100%" }, alignmentStyle]}>
<Image
src={src}
src={resolvedSrc}
style={[pdfStyles.image, width ? { width, maxHeight: 500 } : { maxWidth: 400, maxHeight: 500 }]}
/>
</View>

View File

@@ -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<Buffer | null> => {
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;
};

View File

@@ -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<string> => {
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<PdfExportService>()("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
* `<Image src={src}>` 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<PdfExportService>()("PdfExp
// Resolve URLs first
const resolvedUrlMap = yield* tryAsync(
async () => {
const urlMap = new Map<string, string>();
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<string, string>()
).pipe(recoverWithDefault(new Map<string, string>()));
@@ -210,14 +258,8 @@ export class PdfExportService extends Effect.Service<PdfExportService>()("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<PdfExportService>()("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<PdfExportService>()("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<Record<string, string>> =>
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", {

View File

@@ -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<string> {
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 <Image>.
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);
});
});
});

View File

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