Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
15 changes: 13 additions & 2 deletions open-sse/handlers/imageGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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 }];
}
Expand Down
29 changes: 15 additions & 14 deletions open-sse/handlers/imageUpscale/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>)
: {};

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) };
}
Expand Down Expand Up @@ -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))),
Expand Down Expand Up @@ -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(() => {});

Expand Down
8 changes: 8 additions & 0 deletions src/lib/guardrails/visionBridgeHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
87 changes: 82 additions & 5 deletions tests/unit/guardrails/vision-bridge-claude-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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")), {
Expand Down Expand Up @@ -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,"));
});
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
});
}
102 changes: 102 additions & 0 deletions tests/unit/image-generation-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
Loading
Loading