fix(security): pin the public-only guard on client-supplied image URLs (#13748)

GHSA-34rg-3pqj-35g9. `fetchRemoteImage()` defaults to
`getProviderOutboundGuard()` — the OPERATOR outbound policy, local-first by
design so self-hosted providers on loopback/LAN keep working. Since #11062 added
the `block-metadata` middle tier, a default install resolves to that mode: the
string check only rejects 169.254/16 and the IMDS hostnames, and the DNS
validation step is skipped entirely (it only runs under `public-only`).

Three sinks feed that default with CALLER input, so a request body could make
the server fetch `http://127.0.0.1:…` or any RFC-1918 host and forward the bytes
upstream:

- imageGeneration.ts `resolveImageSource()` — `image_url`, `mask_url`, message parts
- imageUpscale/shared.ts `resolveUpscaleImageSource()` — 14 body aliases,
  `provider_options.*`, message parts (Stability, Topaz)
- visionBridgeHelpers.ts `fetchRemoteImageAsDataUri()` — chat `image_url` parts
  inlined into the vision self-call

plus the NanoBanana result-URL download, which is upstream-supplied rather than
OmniRoute-controlled.

Same trust confusion as GHSA-3f8g / GHSA-j7j4 on the search base URL: operator
config and caller input must not share a guard. Each site now passes
`guard: "public-only"` (string check + DNS validation of every answer), matching
the siblings that already did it right — embeddings, the audio bridge and the
AI Horde result download.

`pinDns` is set only on the vision bridge. The other three sites use
`globalThis.fetch`, and connection pinning would swap that for a raw undici
fetch — the same reason the AI Horde site leaves it off. On the vision bridge a
`fetchImpl` is injected, so `pinDns` there validates every DNS answer but cannot
pin the connection; commented in place.

Blind SSRF rather than full read: the bytes go upstream or into the vision
self-call, not back to the caller — but the status oracle and upstream
exfiltration are real.

Tests are red-first — per sink, `http://127.0.0.1:1/x.png` and
`http://192.168.1.50/x.png` are rejected with the injected fetch never called,
and a public host whose DNS resolves to a public IP still downloads.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-15 13:24:50 -03:00
committed by GitHub
parent e498c349e3
commit b97338a803
9 changed files with 480 additions and 33 deletions

View File

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

View File

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

View File

@@ -71,7 +71,9 @@ export function extractUpscaleSourceImage(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const b = body as Record<string, unknown>;
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<string, unknown>)
: {};
@@ -161,7 +163,15 @@ export async function resolveUpscaleImageSource(source: string): Promise<Upscale
}
if (/^https?:\/\//i.test(trimmed)) {
const remote = await fetchRemoteImage(trimmed);
// GHSA-34rg-3pqj-35g9: `source` is caller input (14 body aliases, `provider_options.*`,
// message parts) — pin `public-only` explicitly (string check + DNS validation of
// every resolved answer). Never let it fall back to 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 upload the bytes to the upscale
// provider. `pinDns` stays off on purpose: the download transport here is
// `globalThis.fetch` and connection pinning replaces it with a raw undici fetch —
// same shape as the AI Horde result download in `imageGeneration/providers/aihorde.ts`.
const remote = await fetchRemoteImage(trimmed, { guard: "public-only" });
assertSourceBytes(remote.buffer);
// fetchRemoteImage falls back to application/octet-stream; sniff whenever the
// server did not send a usable image/* type so multipart uploads stay correct.
@@ -214,11 +224,7 @@ export function sniffImageMime(buffer: Buffer): string {
*/
export function readImageDimensions(buffer: Buffer): { width: number; height: number } | null {
try {
if (
buffer.length >= 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(() => {});

View File

@@ -309,6 +309,14 @@ async function fetchRemoteImageAsDataUri(
fetchImpl: typeof fetch = VISION_BRIDGE_UA_FETCH
): Promise<string> {
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.

View File

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

View File

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

View File

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

View File

@@ -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<T>(run: () => Promise<T>): Promise<T> {
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;
}
});

View File

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