mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 22:02:19 +03:00
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.
208 lines
6.7 KiB
TypeScript
208 lines
6.7 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import dns from "node:dns";
|
|
|
|
import { handleImageGeneration } from "../../open-sse/handlers/imageGeneration.ts";
|
|
|
|
// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard
|
|
// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts).
|
|
// The b64_json test mocks globalThis.fetch with an example.com URL that
|
|
// doesn't resolve in CI; the handler invokes fetchRemoteImage without
|
|
// exposing a `lookup` injection point, so we monkey-patch dns.promises.lookup
|
|
// to always return a public IP so the rebinding guard passes and the test
|
|
// exercises the mocked fetch behaviour as intended.
|
|
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;
|
|
});
|
|
|
|
test("handleImageGeneration(nanobanana): async submit+poll returns URL payload", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
let pollCount = 0;
|
|
|
|
globalThis.fetch = async (url, options = {}) => {
|
|
const u = String(url);
|
|
|
|
if (u.includes("/generate-pro")) {
|
|
const body = JSON.parse(options.body);
|
|
assert.equal(body.prompt, "galaxy test");
|
|
assert.equal(body.aspectRatio, "1:1");
|
|
assert.equal(body.resolution, "2K");
|
|
|
|
return new Response(
|
|
JSON.stringify({ code: 200, msg: "success", data: { taskId: "task-handler-1" } }),
|
|
{ status: 200, headers: { "content-type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
if (u.includes("/record-info")) {
|
|
pollCount += 1;
|
|
if (pollCount < 2) {
|
|
return new Response(
|
|
JSON.stringify({ code: 200, msg: "success", data: { successFlag: 0 } }),
|
|
{
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}
|
|
);
|
|
}
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
code: 200,
|
|
msg: "success",
|
|
data: {
|
|
successFlag: 1,
|
|
response: { resultImageUrl: "https://cdn.example.com/handler-result.jpg" },
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
throw new Error(`Unexpected URL: ${u}`);
|
|
};
|
|
|
|
try {
|
|
const result = await handleImageGeneration({
|
|
body: {
|
|
model: "nanobanana/nanobanana-pro",
|
|
prompt: "galaxy test",
|
|
size: "1024x1280",
|
|
poll_interval_ms: 1,
|
|
},
|
|
credentials: { apiKey: "test-key" },
|
|
log: null,
|
|
});
|
|
|
|
assert.equal(result.success, true);
|
|
assert.equal(result.data.data.length, 1);
|
|
assert.equal(result.data.data[0].url, "https://cdn.example.com/handler-result.jpg");
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test("handleImageGeneration(nanobanana): response_format=b64_json converts URL to b64", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
globalThis.fetch = async (url) => {
|
|
const u = String(url);
|
|
|
|
if (u.includes("/generate")) {
|
|
return new Response(
|
|
JSON.stringify({ code: 200, msg: "success", data: { taskId: "task-handler-2" } }),
|
|
{ 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: "https://cdn.example.com/handler-result-2.jpg" },
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
if (u.includes("handler-result-2.jpg")) {
|
|
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, true);
|
|
assert.equal(result.data.data.length, 1);
|
|
assert.equal(result.data.data[0].b64_json, "iVBORw==");
|
|
} finally {
|
|
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;
|
|
}
|
|
});
|
|
}
|