mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 20:02:45 +03:00
fix(security): pin the public-only guard on client-supplied image URLs
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:
@@ -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)
|
||||
@@ -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 }];
|
||||
}
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,"));
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user