diff --git a/src/shared/network/remoteImageFetch.ts b/src/shared/network/remoteImageFetch.ts index 2e8fc130b1..2f982a0bd9 100644 --- a/src/shared/network/remoteImageFetch.ts +++ b/src/shared/network/remoteImageFetch.ts @@ -4,6 +4,7 @@ import { Agent, fetch as undiciFetch } from "undici"; import { type OutboundUrlGuardMode, isPrivateHost, + parseAndValidateNonMetadataUrl, parseAndValidatePublicUrl, parseOutboundUrl, } from "@/shared/network/outboundUrlGuard"; @@ -51,7 +52,9 @@ export type RemoteMediaFetchOptions = RemoteImageFetchOptions; export type RemoteMediaFetchResult = RemoteImageFetchResult; function validateRemoteImageUrl(input: string | URL, guard: OutboundUrlGuardMode) { - return guard === "public-only" ? parseAndValidatePublicUrl(input) : parseOutboundUrl(input); + if (guard === "public-only") return parseAndValidatePublicUrl(input); + if (guard === "block-metadata") return parseAndValidateNonMetadataUrl(input); + return parseOutboundUrl(input); } function requireHttps(url: URL, enabled: boolean): URL { diff --git a/tests/unit/remote-image-fetch.test.ts b/tests/unit/remote-image-fetch.test.ts index 80ff9e3e7c..8014397165 100644 --- a/tests/unit/remote-image-fetch.test.ts +++ b/tests/unit/remote-image-fetch.test.ts @@ -56,3 +56,52 @@ test("fetchRemoteImage blocks redirects to private image hosts", async () => { /Blocked private or local provider URL/ ); }); + +// The default guard mode (no `guard` option passed, matching production callers that rely on +// `getProviderOutboundGuard()`'s local-first default) is "block-metadata". Every other test in +// this file passes `guard: "public-only"` explicitly, which never exercised this branch — the +// gap that let `validateRemoteImageUrl()`'s fall-through to the unchecked `parseOutboundUrl()` +// for cloud-metadata hosts go undetected. +test("fetchRemoteImage blocks cloud-metadata hosts under the default block-metadata guard", async () => { + let called = false; + + await assert.rejects( + () => + fetchRemoteImage("http://169.254.169.254/latest/meta-data", { + fetchImpl: async () => { + called = true; + return new Response("unexpected"); + }, + }), + /Blocked cloud-metadata endpoint/ + ); + + assert.equal(called, false); +}); + +test("fetchRemoteImage allows private/LAN image hosts under the default block-metadata guard", async () => { + const result = await fetchRemoteImage("http://192.168.1.50:8080/local.png", { + fetchImpl: async () => + new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { "content-type": "image/png" }, + }), + }); + + assert.equal(result.buffer.toString("base64"), "AQID"); +}); + +test("fetchRemoteImage blocks redirects to cloud-metadata hosts under the default block-metadata guard", async () => { + await assert.rejects( + () => + fetchRemoteImage("https://cdn.example.com/redirect.png", { + fetchImpl: async () => + new Response(null, { + status: 302, + headers: { location: "http://169.254.169.254/latest/meta-data" }, + }), + lookup: publicLookup, + }), + /Blocked cloud-metadata endpoint/ + ); +});