diff --git a/changelog.d/fixes/ghsa-34rg-image-url-ssrf-public-only.md b/changelog.d/fixes/ghsa-34rg-image-url-ssrf-public-only.md new file mode 100644 index 0000000000..7835ca750a --- /dev/null +++ b/changelog.d/fixes/ghsa-34rg-image-url-ssrf-public-only.md @@ -0,0 +1 @@ +- **fix(security):** client-supplied image URLs (`image_url` / `mask_url` / message parts on image generation and upscale, chat `image_url` parts inlined by the vision bridge) and the NanoBanana result download now pin the `public-only` outbound guard with DNS validation, instead of inheriting the operator provider policy — `block-metadata` on a default install let a request body make the server fetch loopback/LAN URLs and forward the bytes upstream (GHSA-34rg-3pqj-35g9) diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 8c3596e14d..bd6f1b68aa 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -2243,7 +2243,15 @@ async function resolveImageSource(source) { } if (isHttpUrl(trimmed)) { - const remoteImage = await fetchRemoteImage(trimmed); + // GHSA-34rg-3pqj-35g9: this URL is caller input (`image_url` / `mask_url` / message + // parts) — pin `public-only` explicitly (string check + DNS validation of every + // resolved answer). Never let it fall back to the operator outbound policy + // (`getProviderOutboundGuard()`), which is `block-metadata` on a local-first default + // install and would let a request body make the server fetch loopback/LAN URLs and + // forward the bytes upstream. `pinDns` stays off on purpose: this handler's only + // transport is `globalThis.fetch` (no `fetchImpl` seam) and connection pinning + // replaces it with a raw undici fetch — same shape as the AI Horde result download. + const remoteImage = await fetchRemoteImage(trimmed, { guard: "public-only" }); return { buffer: remoteImage.buffer, base64: remoteImage.buffer.toString("base64"), @@ -3242,7 +3250,10 @@ async function normalizeNanoBananaTaskResult(taskData, body, log) { if (urlCandidates.length > 0) { const firstUrl = urlCandidates[0]; - const remoteImage = await fetchRemoteImage(firstUrl); + // GHSA-34rg-3pqj-35g9: upstream-supplied result URL, not an OmniRoute-controlled + // host — pin `public-only` exactly like the AI Horde result download does, never + // the operator outbound policy (see `resolveImageSource` for why `pinDns` is off). + const remoteImage = await fetchRemoteImage(firstUrl, { guard: "public-only" }); const base64 = remoteImage.buffer.toString("base64"); return [{ b64_json: base64, revised_prompt: body.prompt }]; } diff --git a/open-sse/handlers/imageUpscale/shared.ts b/open-sse/handlers/imageUpscale/shared.ts index cf37e99910..557274b88b 100644 --- a/open-sse/handlers/imageUpscale/shared.ts +++ b/open-sse/handlers/imageUpscale/shared.ts @@ -71,7 +71,9 @@ export function extractUpscaleSourceImage(body: unknown): string | null { if (!body || typeof body !== "object") return null; const b = body as Record; const providerOptions = - b.provider_options && typeof b.provider_options === "object" && !Array.isArray(b.provider_options) + b.provider_options && + typeof b.provider_options === "object" && + !Array.isArray(b.provider_options) ? (b.provider_options as Record) : {}; @@ -161,7 +163,15 @@ export async function resolveUpscaleImageSource(source: string): Promise= 24 && - buffer[0] === 0x89 && - buffer.toString("ascii", 1, 4) === "PNG" - ) { + if (buffer.length >= 24 && buffer[0] === 0x89 && buffer.toString("ascii", 1, 4) === "PNG") { // IHDR is always the first chunk: 8-byte signature + 4 length + 4 "IHDR". return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; } @@ -308,10 +314,7 @@ export function scaleDimensions( const source = readImageDimensions(buffer); if (!source || source.width <= 0 || source.height <= 0) return null; const safeFactor = Number.isFinite(factor) && factor > 0 ? factor : 2; - const scale = Math.min( - safeFactor, - maxEdge / Math.max(source.width, source.height) - ); + const scale = Math.min(safeFactor, maxEdge / Math.max(source.width, source.height)); return { width: Math.max(1, Math.round(source.width * Math.max(1, scale))), height: Math.max(1, Math.round(source.height * Math.max(1, scale))), @@ -365,9 +368,7 @@ export function saveUpscaleErrorResult(opts: { provider: opts.provider, duration: Date.now() - opts.startTime, error: - typeof opts.error === "string" - ? opts.error.slice(0, 500) - : String(opts.error).slice(0, 500), + typeof opts.error === "string" ? opts.error.slice(0, 500) : String(opts.error).slice(0, 500), requestBody: opts.requestBody ?? null, }).catch(() => {}); diff --git a/src/lib/guardrails/visionBridgeHelpers.ts b/src/lib/guardrails/visionBridgeHelpers.ts index 02c99f0e94..dd9b4d45e1 100644 --- a/src/lib/guardrails/visionBridgeHelpers.ts +++ b/src/lib/guardrails/visionBridgeHelpers.ts @@ -309,6 +309,14 @@ async function fetchRemoteImageAsDataUri( fetchImpl: typeof fetch = VISION_BRIDGE_UA_FETCH ): Promise { const remoteImage = await fetchRemoteImage(imageUrl, { + // GHSA-34rg-3pqj-35g9: `imageUrl` is caller input (a chat `image_url` part) — pin + // `public-only` explicitly; never the operator outbound policy (`block-metadata` on a + // local-first default install), which would let a request body make the server + // fetch loopback/LAN URLs and inline the bytes into the vision self-call. + guard: "public-only", + // `pinDns` is validation-only here: with `fetchImpl` injected the library validates + // every DNS answer but cannot pin the connection (it never builds its own fetch). + pinDns: true, signal, // Bypass the runtime's hooked global fetch (ProxyFetch) — a dead local // proxy (e.g. 127.0.0.1:8317) would otherwise break the download. diff --git a/tests/unit/guardrails/vision-bridge-claude-wire.test.ts b/tests/unit/guardrails/vision-bridge-claude-wire.test.ts index 7cba4f4044..2a7b4179e3 100644 --- a/tests/unit/guardrails/vision-bridge-claude-wire.test.ts +++ b/tests/unit/guardrails/vision-bridge-claude-wire.test.ts @@ -5,11 +5,28 @@ */ import test from "node:test"; import assert from "node:assert/strict"; +import dns from "node:dns"; -const { - isClaudeWireFormatModel, - ensureBase64ImagesForClaudeWire, -} = await import("../../../src/lib/guardrails/visionBridgeHelpers.ts"); +// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard +// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts). +// Since GHSA-34rg-3pqj-35g9 the vision bridge pins `guard: "public-only"`, so every +// remote image hostname is resolved before the injected fetch is reached; the +// example.com hosts below must not depend on real DNS in CI. Node --test runs each +// file in its own process, so this rebinding does not leak across files. +const originalDnsLookup = dns.promises.lookup; +(dns.promises as { lookup: unknown }).lookup = (async ( + _hostname: string, + options?: { all?: boolean } +) => { + const record = { address: "203.0.113.1", family: 4 }; + return options && options.all ? [record] : record; +}) as typeof dns.promises.lookup; +process.on("exit", () => { + (dns.promises as { lookup: unknown }).lookup = originalDnsLookup; +}); + +const { isClaudeWireFormatModel, ensureBase64ImagesForClaudeWire } = + await import("../../../src/lib/guardrails/visionBridgeHelpers.ts"); test("isClaudeWireFormatModel: true for anthropic and claude-format registry providers", () => { assert.strictEqual(isClaudeWireFormatModel("anthropic/claude-sonnet-4"), true); @@ -61,7 +78,8 @@ test("ensureBase64ImagesForClaudeWire: keeps data-URI images as-is", async () => }); test("ensureBase64ImagesForClaudeWire: resolves remote URLs to base64 for claude-wire targets", async () => { - const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + const pngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; const originalFetch = globalThis.fetch; globalThis.fetch = async () => new Response(new Uint8Array(Buffer.from(pngBase64, "base64")), { @@ -127,3 +145,62 @@ test("ensureBase64ImagesForClaudeWire: fail-open when the remote fetch fails", a globalThis.fetch = originalFetch; } }); + +// GHSA-34rg-3pqj-35g9 — the vision bridge inlines a user-supplied `image_url` to base64 for +// claude-wire targets (`visionBridge.ts` reroute) and for the Anthropic describe self-call. +// `fetchRemoteImageAsDataUri()` called `fetchRemoteImage()` with only `{ signal, fetchImpl }`, +// so the URL was validated under the OPERATOR outbound policy (`block-metadata` on a +// default install: loopback/LAN allowed, DNS check skipped) instead of `public-only`. The +// helper is fail-open, so the observable contract is: the injected fetch is NEVER invoked +// for a private host and the part is left untouched (not inlined). +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`ensureBase64ImagesForClaudeWire: never fetches a private image_url (${privateUrl}) (GHSA-34rg-3pqj-35g9)`, async () => { + const fetchedUrls: string[] = []; + const body = { + model: "zai/glm-5", + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: privateUrl } }], + }, + ], + }; + + const out = await ensureBase64ImagesForClaudeWire(body, "zai/glm-5", async (input) => { + fetchedUrls.push(String(input)); + // Canary: on the vulnerable code these bytes are inlined into the rerouted body. + return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + + assert.deepStrictEqual(fetchedUrls, [], "the private URL must never be fetched"); + const part = out.messages[0].content[0]; + assert.strictEqual(part.image_url.url, privateUrl, "part must be left untouched (fail-open)"); + }); +} + +test("ensureBase64ImagesForClaudeWire: still inlines a public image_url whose DNS resolves to a public IP (GHSA-34rg-3pqj-35g9)", async () => { + // The module-level DNS stub answers a public IP, so the `public-only` rebinding guard + // passes and the injected fetch is reached. + const fetchedUrls: string[] = []; + const body = { + model: "zai/glm-5", + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "https://cdn.example.com/public.png" } }], + }, + ], + }; + const out = await ensureBase64ImagesForClaudeWire(body, "zai/glm-5", async (input) => { + fetchedUrls.push(String(input)); + return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + assert.deepStrictEqual(fetchedUrls, ["https://cdn.example.com/public.png"]); + assert.ok(out.messages[0].content[0].image_url.url.startsWith("data:image/png;base64,")); +}); diff --git a/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts b/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts index 0303a9a885..a4baa63a3f 100644 --- a/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts +++ b/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts @@ -417,3 +417,46 @@ test("callVisionModel propagates an external abort to fetch and stops before fal globalThis.fetch = originalFetch; } }); + +// GHSA-34rg-3pqj-35g9 — the Anthropic describe self-call inlines the user's image URL to +// base64 through the same `fetchRemoteImageAsDataUri()` sink as the claude-wire reroute. +// The DNS stub at the top of this file answers a public IP for every hostname, so only the +// `public-only` string check stands between the request body and a loopback/RFC-1918 fetch. +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`callVisionModel never fetches a private image URL (${privateUrl}) for the Anthropic describe path (GHSA-34rg-3pqj-35g9)`, async () => { + const fetchedUrls: string[] = []; + const fetchImpl: typeof fetch = async (url) => { + const requestUrl = String(url); + fetchedUrls.push(requestUrl); + if (requestUrl === privateUrl) { + // Canary: on the vulnerable code these bytes are inlined into the Anthropic body. + return new Response(Buffer.from("intranet-bytes"), { + status: 200, + headers: { "Content-Type": "image/png" }, + }); + } + return new Response(JSON.stringify({ content: [{ type: "text", text: "described" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const config: VisionModelConfig = { + model: "anthropic/claude-3-haiku", + prompt: "Describe this image", + timeoutMs: 30000, + maxImages: 10, + fetchImpl, + }; + + await assert.rejects( + () => callVisionModel(privateUrl, config, "sk-ant", { maxFallbackAttempts: 1 }), + /blocked/i + ); + assert.deepStrictEqual( + fetchedUrls, + [], + "neither the private download nor the self-call may happen" + ); + }); +} diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index 9842b956cd..4092f3e807 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -2130,3 +2130,105 @@ test("handleImageGeneration (codex) does not mark an ordinary 400 as retryable", globalThis.fetch = originalFetch; } }); + +// GHSA-34rg-3pqj-35g9 — caller-supplied image URLs (`image_url` / `mask_url` / message +// parts) reach `fetchRemoteImage()` through `resolveImageSource()`. Without an explicit +// `guard`, the library falls back to `getProviderOutboundGuard()` — the OPERATOR outbound +// policy, which is `block-metadata` on a default install (LAN/loopback allowed, DNS +// rebinding check skipped) — so a request body could make the server fetch intranet +// URLs and forward the bytes upstream. Caller input must be pinned to `public-only` +// regardless of the operator policy. The DNS stub at the top of this file resolves every +// hostname to a public IP, so the string check is the only thing standing between the +// request body and the loopback/RFC-1918 fetch. +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`handleImageGeneration rejects a private image_url (${privateUrl}) before any fetch (GHSA-34rg-3pqj-35g9)`, async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls = []; + + globalThis.fetch = async (url) => { + const stringUrl = String(url); + fetchedUrls.push(stringUrl); + if (stringUrl === privateUrl) { + // Canary: on the vulnerable code the sink downloads these bytes and + // forwards them to Stability as the multipart `image` part. + return new Response(new Uint8Array([4, 5]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + } + return new Response(JSON.stringify({ image: "c3RhYmlsaXR5LWltYWdl" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "stability-ai/inpaint", + prompt: "replace the sky with aurora", + image_url: privateUrl, + mask: "data:image/png;base64,AA==", + response_format: "b64_json", + }, + credentials: { apiKey: "stability-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.match(String(result.error), /blocked/i); + assert.deepEqual( + fetchedUrls, + [], + "neither the private image download nor the upstream call may happen" + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +} + +test("handleImageGeneration still downloads a public image_url whose DNS resolves to a public IP (GHSA-34rg-3pqj-35g9)", async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls = []; + let requestCapture; + + globalThis.fetch = async (url, options = {}) => { + const stringUrl = String(url); + fetchedUrls.push(stringUrl); + if (stringUrl === "https://cdn.example.com/public-input.png") { + return new Response(new Uint8Array([4, 5, 6]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + } + if (stringUrl === "https://api.stability.ai/v2beta/stable-image/edit/inpaint") { + requestCapture = { body: options.body }; + return new Response(JSON.stringify({ image: "c3RhYmlsaXR5LWltYWdl" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "stability-ai/inpaint", + prompt: "replace the sky with aurora", + image_url: "https://cdn.example.com/public-input.png", + mask: "data:image/png;base64,AA==", + response_format: "b64_json", + }, + credentials: { apiKey: "stability-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(fetchedUrls[0], "https://cdn.example.com/public-input.png"); + assert.equal((requestCapture.body.get("image") as Blob).size, 3); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/image-upscale.test.ts b/tests/unit/image-upscale.test.ts index b6eda4c365..851be6e0bc 100644 --- a/tests/unit/image-upscale.test.ts +++ b/tests/unit/image-upscale.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert"; +import dns from "node:dns"; import { DEFAULT_UPSCALE_FACTORS, UPSCALE_PROVIDERS, @@ -25,6 +26,7 @@ import { import { extractUpscaleSourceImage, readImageDimensions, + resolveUpscaleImageSource, scaleDimensions, sniffImageMime, } from "../../open-sse/handlers/imageUpscale/shared.ts"; @@ -67,7 +69,12 @@ function jpegHeader(width: number, height: number): Buffer { const FAKE_JWT = (() => { const header = Buffer.from(JSON.stringify({ alg: "RS256" })).toString("base64url"); const payload = Buffer.from( - JSON.stringify({ user_id: "TESTUSER@AdobeID", type: "access_token", created_at: "1", expires_in: "86400000" }) + JSON.stringify({ + user_id: "TESTUSER@AdobeID", + type: "access_token", + created_at: "1", + expires_in: "86400000", + }) ).toString("base64url"); return `${header}.${payload}.sig`; })(); @@ -104,7 +111,12 @@ test("adobe-firefly upscale models are Topaz only (video starlight/astra exclude const ids = UPSCALE_PROVIDERS["adobe-firefly"]!.models.map((m) => m.id); assert.deepEqual(ids, ["topaz", "topaz-standard", "topaz-bloom"]); for (const id of ids) assert.ok(id.startsWith("topaz"), `${id} must be a Topaz model`); - for (const forbidden of ["starlight-quality", "starlight-creative", "starlight-fast", "astra-2"]) { + for (const forbidden of [ + "starlight-quality", + "starlight-creative", + "starlight-fast", + "astra-2", + ]) { assert.ok(!ids.includes(forbidden), `${forbidden} is a video upscaler and must not be listed`); } }); @@ -133,7 +145,10 @@ test("parseUpscaleModel accepts provider prefix, alias and bare model ids", () = provider: "stability-ai", model: "creative", }); - assert.deepEqual(parseUpscaleModel("topaz-enhance"), { provider: "topaz", model: "topaz-enhance" }); + assert.deepEqual(parseUpscaleModel("topaz-enhance"), { + provider: "topaz", + model: "topaz-enhance", + }); assert.equal(parseUpscaleModel("openai/gpt-image-2").provider, null); assert.deepEqual(parseUpscaleModel(null), { provider: null, model: null }); }); @@ -211,7 +226,10 @@ test("resolveAdobeUpscaleModel maps ids to upstream topaz versions and rejects o resolveAdobeUpscaleModel("adobe-firefly/topaz-bloom")?.spec.upstreamModelId, "topaz" ); - assert.equal(resolveAdobeUpscaleModel("firefly/reimagine")?.spec.upstreamModelVersion, "reimagine"); + assert.equal( + resolveAdobeUpscaleModel("firefly/reimagine")?.spec.upstreamModelVersion, + "reimagine" + ); assert.equal(resolveAdobeUpscaleModel("nano-banana-pro"), null); assert.equal(resolveAdobeUpscaleModel(""), null); assert.equal(isAdobeFireflyUpscaleModel("topaz-bloom"), true); @@ -232,7 +250,10 @@ test("resolveAdobeCreativityLevel maps 0-100 % onto the 0-1 upsample wire float" assert.equal(resolveAdobeCreativityLevel({ creativityPercent: 40 }), 0.4); assert.equal(resolveAdobeCreativityLevel({}), 0); // Explicit 0-1 wins over percent. - assert.equal(resolveAdobeCreativityLevel({ creativityPercent: 100, creativityLevel: 0.25 }), 0.25); + assert.equal( + resolveAdobeCreativityLevel({ creativityPercent: 100, creativityLevel: 0.25 }), + 0.25 + ); // Legacy 1-5 integer scale (discovery docs) is mapped onto 0-1. assert.equal(resolveAdobeCreativityLevel({ creativityLevel: "4" }), 0.8); assert.equal(resolveAdobeCreativityLevel({ creativityLevel: 5 }), 1); @@ -359,9 +380,15 @@ test("adobeFireflyUpscaleImage rejects a non-upscale model and a missing blob", // ── Shared helpers ───────────────────────────────────────────────────────── test("extractUpscaleSourceImage finds the first image across every alias", () => { - assert.equal(extractUpscaleSourceImage({ image: "data:image/png;base64,AAA" }), "data:image/png;base64,AAA"); + assert.equal( + extractUpscaleSourceImage({ image: "data:image/png;base64,AAA" }), + "data:image/png;base64,AAA" + ); assert.equal(extractUpscaleSourceImage({ image_url: "https://x/y.png" }), "https://x/y.png"); - assert.equal(extractUpscaleSourceImage({ images: ["https://a/1.png", "https://a/2.png"] }), "https://a/1.png"); + assert.equal( + extractUpscaleSourceImage({ images: ["https://a/1.png", "https://a/2.png"] }), + "https://a/1.png" + ); assert.equal( extractUpscaleSourceImage({ image_url: { url: "https://obj/u.png" } }), "https://obj/u.png" @@ -372,7 +399,9 @@ test("extractUpscaleSourceImage finds the first image across every alias", () => ); assert.equal( extractUpscaleSourceImage({ - messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://m/1.png" } }] }], + messages: [ + { role: "user", content: [{ type: "image_url", image_url: { url: "https://m/1.png" } }] }, + ], }), "https://m/1.png" ); @@ -409,7 +438,10 @@ test("scaleDimensions multiplies the source size and clamps the long edge", () = // ── Dispatcher ───────────────────────────────────────────────────────────── test("handleImageUpscale rejects unknown / mismatched models before any network call", async () => { - const badModel = await handleImageUpscale({ body: { model: "openai/gpt-image-2" }, credentials: {} }); + const badModel = await handleImageUpscale({ + body: { model: "openai/gpt-image-2" }, + credentials: {}, + }); assert.equal(badModel.success, false); assert.equal(badModel.status, 400); assert.match(String(badModel.error), /Invalid upscale model/); @@ -428,7 +460,11 @@ test("handleImageUpscale rejects unknown / mismatched models before any network }); test("handleImageUpscale requires a source image for every provider", async () => { - for (const model of ["adobe-firefly/topaz-standard", "stability-ai/fast", "topaz/topaz-enhance"]) { + for (const model of [ + "adobe-firefly/topaz-standard", + "stability-ai/fast", + "topaz/topaz-enhance", + ]) { const result = await handleImageUpscale({ body: { model }, credentials: { apiKey: "k" }, @@ -590,7 +626,10 @@ test("topaz falls back to its own scale when the source dimensions are unreadabl credentials: { apiKey: "topaz-key" }, fetchImpl: (async (_url: unknown, init?: RequestInit) => { form = init?.body as FormData; - return new Response(bytes(PNG_1X1), { status: 200, headers: { "content-type": "image/png" } }); + return new Response(bytes(PNG_1X1), { + status: 200, + headers: { "content-type": "image/png" }, + }); }) as unknown as typeof fetch, }); @@ -614,7 +653,10 @@ test("topaz honors an explicit WxH size over the factor and propagates upstream credentials: { apiKey: "topaz-key" }, fetchImpl: (async (_url: unknown, init?: RequestInit) => { form = init?.body as FormData; - return new Response(bytes(PNG_1X1), { status: 200, headers: { "content-type": "image/png" } }); + return new Response(bytes(PNG_1X1), { + status: 200, + headers: { "content-type": "image/png" }, + }); }) as unknown as typeof fetch, }); assert.equal(form!.get("output_width"), "1500"); @@ -633,3 +675,102 @@ test("topaz honors an explicit WxH size over the factor and propagates upstream assert.equal(failed.status, 402); assert.match(String(failed.error), /quota exceeded/); }); + +// ── GHSA-34rg-3pqj-35g9 — caller-supplied source URL must be public-only ─── +// +// `resolveUpscaleImageSource()` is fed straight from the request body (14 aliases, +// `provider_options.*`, message parts). It called `fetchRemoteImage()` with no explicit +// `guard`, so it inherited `getProviderOutboundGuard()` — the OPERATOR outbound policy, +// `block-metadata` on a default install (loopback/LAN allowed, DNS check skipped) — and +// a request body could make the server fetch intranet URLs and upload the bytes upstream. + +/** Public-IP DNS stub (rebinding guard needs a non-empty public answer for a fake host). */ +function withPublicDns(run: () => Promise): Promise { + const originalLookup = dns.promises.lookup; + (dns.promises as { lookup: unknown }).lookup = (async ( + _hostname: string, + options?: { all?: boolean } + ) => { + const record = { address: "203.0.113.1", family: 4 }; + return options && options.all ? [record] : record; + }) as typeof dns.promises.lookup; + return run().finally(() => { + (dns.promises as { lookup: unknown }).lookup = originalLookup; + }); +} + +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`resolveUpscaleImageSource rejects a private source URL (${privateUrl}) before any fetch (GHSA-34rg-3pqj-35g9)`, async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls: string[] = []; + globalThis.fetch = (async (url: string | URL | Request) => { + fetchedUrls.push(String(url)); + return new Response(bytes(PNG_1X1), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }) as unknown as typeof fetch; + + try { + await assert.rejects(() => resolveUpscaleImageSource(privateUrl), /blocked/i); + assert.deepEqual(fetchedUrls, [], "the private URL must never be fetched"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test(`stability upscale never uploads bytes from a private image_url (${privateUrl}) (GHSA-34rg-3pqj-35g9)`, async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls: string[] = []; + globalThis.fetch = (async (url: string | URL | Request) => { + fetchedUrls.push(String(url)); + // Canary: on the vulnerable code these bytes become the multipart `image` part. + return new Response(bytes(PNG_1X1), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }) as unknown as typeof fetch; + let upstreamCalls = 0; + + try { + const result = await handleStabilityImageUpscale({ + model: "fast", + provider: "stability-ai", + providerConfig: { baseUrl: "https://api.stability.ai" }, + body: { image_url: privateUrl, response_format: "b64_json" }, + credentials: { apiKey: "sk-test" }, + fetchImpl: (async () => { + upstreamCalls += 1; + return jsonResponse({ image: PNG_1X1.toString("base64") }); + }) as unknown as typeof fetch, + }); + + assert.equal(result.success, false); + assert.match(String(result.error), /blocked/i); + assert.deepEqual(fetchedUrls, [], "the private URL must never be fetched"); + assert.equal(upstreamCalls, 0, "nothing may be uploaded to the provider"); + } finally { + globalThis.fetch = originalFetch; + } + }); +} + +test("resolveUpscaleImageSource still downloads a public URL whose DNS resolves to a public IP (GHSA-34rg-3pqj-35g9)", async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls: string[] = []; + globalThis.fetch = (async (url: string | URL | Request) => { + fetchedUrls.push(String(url)); + return new Response(bytes(PNG_1X1), { status: 200, headers: { "content-type": "image/png" } }); + }) as unknown as typeof fetch; + + try { + const source = await withPublicDns(() => + resolveUpscaleImageSource("https://cdn.example.com/public.png") + ); + assert.equal(source.contentType, "image/png"); + assert.equal(source.buffer.length, PNG_1X1.length); + assert.deepEqual(fetchedUrls, ["https://cdn.example.com/public.png"]); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/nanobanana-image-handler.test.ts b/tests/unit/nanobanana-image-handler.test.ts index 0e956e6d56..fc438dff46 100644 --- a/tests/unit/nanobanana-image-handler.test.ts +++ b/tests/unit/nanobanana-image-handler.test.ts @@ -142,3 +142,66 @@ test("handleImageGeneration(nanobanana): response_format=b64_json converts URL t globalThis.fetch = originalFetch; } }); + +// GHSA-34rg-3pqj-35g9 — the `response_format=b64_json` path re-fetches the result URL the +// upstream task reports. That URL is upstream-supplied (lower risk than a request-body +// URL), but it went through `fetchRemoteImage()` with no explicit `guard`, i.e. under the +// OPERATOR outbound policy (`block-metadata` on a default install: LAN/loopback allowed, +// DNS check skipped). Pin it to `public-only`, mirroring the AI Horde result download. +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`handleImageGeneration(nanobanana): b64_json never downloads a private result URL (${privateUrl}) (GHSA-34rg-3pqj-35g9)`, async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls: string[] = []; + + globalThis.fetch = async (url) => { + const u = String(url); + fetchedUrls.push(u); + + if (u.includes("/generate")) { + return new Response( + JSON.stringify({ code: 200, msg: "success", data: { taskId: "task-ssrf-1" } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + if (u.includes("/record-info")) { + return new Response( + JSON.stringify({ + code: 200, + msg: "success", + data: { successFlag: 1, response: { resultImageUrl: privateUrl } }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + if (u === privateUrl) { + // Canary: on the vulnerable code these bytes come back to the caller as b64_json. + return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200 }); + } + + throw new Error(`Unexpected URL: ${u}`); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "nanobanana/nanobanana-flash", + prompt: "galaxy test", + response_format: "b64_json", + }, + credentials: { apiKey: "test-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.match(String(result.error), /blocked/i); + assert.ok( + !fetchedUrls.includes(privateUrl), + `the private result URL must never be fetched (fetched: ${fetchedUrls.join(", ")})` + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +}