mirror of
https://github.com/makeplane/plane.git
synced 2026-08-29 10:08:51 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user