mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 14:22:14 +03:00
Merged via /merge-batch (2026-09-19) on top of the current `release/v3.8.51` tip. **Reconciled before landing:** the train-3 ejection from 2026-09-18 (`tests/unit/image-generation-route.test.ts` → promptless topaz request came back 502 because `pinDns: true` bypassed the test's mocked `globalThis.fetch`) is fixed in `5fafc490` by registering the same `setPinnedFetchTestOverride()` seam the other four image tests already use, cleared in `resetStorage()`/`after`. Production keeps pinning for real. **Evidence on the merged tree:** `image-generation-route.test.ts` 25/25; all 18 image-path test files that mock `globalThis.fetch` (handler, upscale, fal, nanobanana, agnes, alibaba, bailian, kie, magnific, minimax, pollinations, qwen, edits-multipart, fetch-timeout, route-auth, pindns-toctou-13883) 183/183; `typecheck:core` clean; `check:open-sse-typecheck` 0 errors; file-size, changelog-integrity, complexity and cognitive-complexity gates OK. Closes #13883
This commit is contained in:
committed by
GitHub
parent
2e0edd2241
commit
ec4d1eff43
1
changelog.d/fixes/13883-image-fetch-pindns.md
Normal file
1
changelog.d/fixes/13883-image-fetch-pindns.md
Normal file
@@ -0,0 +1 @@
|
||||
- **security(images):** close a DNS-rebinding TOCTOU (#13883) at the three newer public-only image download sites — `resolveImageSource` and the NanoBanana result-URL conversion in `imageGeneration.ts`, and `resolveUpscaleImageSource` in `imageUpscale/shared.ts`. All three validated a caller-supplied URL's DNS answer as public but then let the download perform an independent, un-pinned second resolution at connect time, so a host that answered differently between the two lookups (public, then loopback/LAN) could reach an internal address; they now set `pinDns: true` (reusing the existing `createPinnedFetch` helper already used by embeddings and the vision/audio/video bridges), binding the connection to the exact validated address.
|
||||
@@ -2226,7 +2226,7 @@ function extractImageInputs(body) {
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveImageSource(source) {
|
||||
export async function resolveImageSource(source) {
|
||||
if (typeof source !== "string" || source.trim().length === 0) {
|
||||
throw new Error("Invalid image source");
|
||||
}
|
||||
@@ -2243,15 +2243,11 @@ async function resolveImageSource(source) {
|
||||
}
|
||||
|
||||
if (isHttpUrl(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" });
|
||||
// GHSA-34rg-3pqj-35g9 / #13883: caller-input URL — pin `public-only` (never the operator
|
||||
// outbound policy, which would let a request body reach loopback/LAN) and `pinDns: true`
|
||||
// to close the DNS-rebinding TOCTOU where a second, un-pinned resolution at connect time
|
||||
// could answer differently than the validated lookup and bypass the guard.
|
||||
const remoteImage = await fetchRemoteImage(trimmed, { guard: "public-only", pinDns: true });
|
||||
return {
|
||||
buffer: remoteImage.buffer,
|
||||
base64: remoteImage.buffer.toString("base64"),
|
||||
@@ -3242,7 +3238,7 @@ function normalizeNanoBananaSyncPayload(data, prompt) {
|
||||
return { data: images.filter(Boolean) };
|
||||
}
|
||||
|
||||
async function normalizeNanoBananaTaskResult(taskData, body, log) {
|
||||
export async function normalizeNanoBananaTaskResult(taskData, body, log) {
|
||||
const response = taskData?.response || {};
|
||||
|
||||
const urlCandidates = [
|
||||
@@ -3280,10 +3276,10 @@ async function normalizeNanoBananaTaskResult(taskData, body, log) {
|
||||
|
||||
if (urlCandidates.length > 0) {
|
||||
const firstUrl = urlCandidates[0];
|
||||
// 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" });
|
||||
// GHSA-34rg-3pqj-35g9 / #13883: upstream-supplied result URL, not an OmniRoute-
|
||||
// controlled host — pin `public-only`, never the operator outbound policy, and
|
||||
// `pinDns: true` to close the DNS-rebinding TOCTOU (see `resolveImageSource`).
|
||||
const remoteImage = await fetchRemoteImage(firstUrl, { guard: "public-only", pinDns: true });
|
||||
const base64 = remoteImage.buffer.toString("base64");
|
||||
return [{ b64_json: base64, revised_prompt: body.prompt }];
|
||||
}
|
||||
|
||||
@@ -163,15 +163,15 @@ export async function resolveUpscaleImageSource(source: string): Promise<Upscale
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(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" });
|
||||
// GHSA-34rg-3pqj-35g9 / #13883: `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: true` closes the DNS-rebinding TOCTOU: without it, a
|
||||
// second, un-pinned resolution at connect time could answer differently than the
|
||||
// validated lookup and bypass the public-only guard.
|
||||
const remote = await fetchRemoteImage(trimmed, { guard: "public-only", pinDns: true });
|
||||
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.
|
||||
|
||||
@@ -146,6 +146,16 @@ async function readResponseBuffer(response: Response, maxBytes: number) {
|
||||
return Buffer.concat(chunks, totalBytes);
|
||||
}
|
||||
|
||||
// #13883: test-only escape hatch for `pinDns: true` callers that have no `fetchImpl` seam
|
||||
// of their own (imageGeneration.ts / imageUpscale/shared.ts). `createPinnedFetch` opens a
|
||||
// real undici connection, bypassing a test's monkeypatched `globalThis.fetch`; setting this
|
||||
// override lets such a test keep exercising its mock instead of a real network attempt.
|
||||
// Production callers never call the setter, so `pinDns` still pins for real in production.
|
||||
let pinnedFetchTestOverride: typeof fetch | undefined;
|
||||
export function setPinnedFetchTestOverride(fetchImpl: typeof fetch | undefined): void {
|
||||
pinnedFetchTestOverride = fetchImpl;
|
||||
}
|
||||
|
||||
export async function fetchRemoteMedia(
|
||||
input: string | URL,
|
||||
options: RemoteMediaFetchOptions = {}
|
||||
@@ -171,6 +181,7 @@ export async function fetchRemoteMedia(
|
||||
const addresses = await assertHostnameResolvesPublic(currentUrl, guard, lookup);
|
||||
const fetchImpl =
|
||||
injectedFetch ??
|
||||
pinnedFetchTestOverride ??
|
||||
(pinDns && addresses.length
|
||||
? createPinnedFetch(addresses[0].address, addresses[0].family)
|
||||
: fetch);
|
||||
|
||||
@@ -20,10 +20,11 @@ process.on("exit", () => {
|
||||
});
|
||||
|
||||
const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts");
|
||||
const { setPinnedFetchTestOverride } = await import("../../src/shared/network/remoteImageFetch.ts");
|
||||
|
||||
test("handleImageGeneration returns Fal images as base64 when response_format is omitted", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
const mockFetchImpl = async (url) => {
|
||||
const stringUrl = String(url);
|
||||
if (stringUrl === "https://fal.run/fal-ai/flux-2-flex") {
|
||||
return new Response(
|
||||
@@ -39,6 +40,11 @@ test("handleImageGeneration returns Fal images as base64 when response_format is
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${stringUrl}`);
|
||||
};
|
||||
// #13883: resolveImageSource now sets `pinDns: true`, which pins the connection via a
|
||||
// real undici socket and would bypass this mocked globalThis.fetch — route it through
|
||||
// the test-only pinned-fetch override instead (src/shared/network/remoteImageFetch.ts).
|
||||
globalThis.fetch = mockFetchImpl;
|
||||
setPinnedFetchTestOverride(mockFetchImpl);
|
||||
|
||||
try {
|
||||
const result = await handleImageGeneration({
|
||||
@@ -51,5 +57,6 @@ test("handleImageGeneration returns Fal images as base64 when response_format is
|
||||
assert.equal(result.data.data[0].url, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
setPinnedFetchTestOverride(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,16 +7,9 @@ import { join } from "node:path";
|
||||
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-images-"));
|
||||
|
||||
// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard
|
||||
// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts).
|
||||
// Several image-handler tests (Fal AI URL->b64 normalization, BFL polling
|
||||
// with base64 input images, NanoBanana polling with URL->b64 conversion)
|
||||
// mock globalThis.fetch with example.com URLs that don'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. Node --test runs each file in its own process, so
|
||||
// this rebinding does not leak across files.
|
||||
// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx guard so mocked example.com URLs
|
||||
// resolve as public. #13883's `pinDns: true` pins the connection via undici, bypassing a
|
||||
// mocked globalThis.fetch — `mockFetch()` also sets the `setPinnedFetchTestOverride()` seam.
|
||||
const originalDnsLookup = dns.promises.lookup;
|
||||
(dns.promises as { lookup: unknown }).lookup = (async (
|
||||
_hostname: string,
|
||||
@@ -32,6 +25,11 @@ process.on("exit", () => {
|
||||
const { IMAGE_PROVIDERS, parseImageModel, getAllImageModels } =
|
||||
await import("../../open-sse/config/imageRegistry.ts");
|
||||
const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts");
|
||||
const { setPinnedFetchTestOverride } = await import("../../src/shared/network/remoteImageFetch.ts");
|
||||
function mockFetch(impl) {
|
||||
globalThis.fetch = impl;
|
||||
setPinnedFetchTestOverride(impl);
|
||||
}
|
||||
|
||||
function immediateTimeout(callback, _ms, ...args) {
|
||||
if (typeof callback === "function") callback(...args);
|
||||
@@ -366,7 +364,7 @@ test("handleImageGeneration calls Fal AI with Key auth and normalizes URL result
|
||||
const originalFetch = globalThis.fetch;
|
||||
let requestCapture;
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
mockFetch(async (url, options = {}) => {
|
||||
const stringUrl = String(url);
|
||||
if (stringUrl === "https://fal.run/fal-ai/flux-pro/v1.1-ultra") {
|
||||
requestCapture = {
|
||||
@@ -391,7 +389,7 @@ test("handleImageGeneration calls Fal AI with Key auth and normalizes URL result
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected URL: ${stringUrl}`);
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await handleImageGeneration({
|
||||
@@ -416,7 +414,7 @@ test("handleImageGeneration calls Fal AI with Key auth and normalizes URL result
|
||||
assert.equal(requestCapture.body.sync_mode, true);
|
||||
assert.equal(result.data.data[0].b64_json, "BQYH");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
mockFetch(originalFetch);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -424,7 +422,7 @@ test("handleImageGeneration routes Stability AI edit models to native endpoints"
|
||||
const originalFetch = globalThis.fetch;
|
||||
let requestCapture;
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
mockFetch(async (url, options = {}) => {
|
||||
const stringUrl = String(url);
|
||||
if (stringUrl === "https://example.com/stability-input.png") {
|
||||
return new Response(new Uint8Array([4, 5]), {
|
||||
@@ -447,7 +445,7 @@ test("handleImageGeneration routes Stability AI edit models to native endpoints"
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected URL: ${stringUrl}`);
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await handleImageGeneration({
|
||||
@@ -476,7 +474,7 @@ test("handleImageGeneration routes Stability AI edit models to native endpoints"
|
||||
assert.equal((requestCapture.body.get("mask") as Blob).size, 1);
|
||||
assert.equal(result.data.data[0].b64_json, "c3RhYmlsaXR5LWltYWdl");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
mockFetch(originalFetch);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -537,7 +535,7 @@ test("handleImageGeneration polls Black Forest Labs results and sends base64 inp
|
||||
let pollCapture;
|
||||
|
||||
globalThis.setTimeout = immediateTimeout;
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
mockFetch(async (url, options = {}) => {
|
||||
const stringUrl = String(url);
|
||||
if (stringUrl === "https://example.com/bfl-input.png") {
|
||||
return new Response(new Uint8Array([1, 2]), {
|
||||
@@ -582,7 +580,7 @@ test("handleImageGeneration polls Black Forest Labs results and sends base64 inp
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected URL: ${stringUrl}`);
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await handleImageGeneration({
|
||||
@@ -605,7 +603,7 @@ test("handleImageGeneration polls Black Forest Labs results and sends base64 inp
|
||||
assert.equal(pollCapture.headers["x-key"], "bfl-key");
|
||||
assert.equal(result.data.data[0].b64_json, "CQgH");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
mockFetch(originalFetch);
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
@@ -660,7 +658,7 @@ test("handleImageGeneration uploads source images to Topaz and returns base64 ou
|
||||
const originalFetch = globalThis.fetch;
|
||||
let requestCapture;
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
mockFetch(async (url, options = {}) => {
|
||||
const stringUrl = String(url);
|
||||
if (stringUrl === "https://example.com/topaz-input.png") {
|
||||
return new Response(new Uint8Array([1, 2, 3]), {
|
||||
@@ -686,7 +684,7 @@ test("handleImageGeneration uploads source images to Topaz and returns base64 ou
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected URL: ${stringUrl}`);
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await handleImageGeneration({
|
||||
@@ -709,7 +707,7 @@ test("handleImageGeneration uploads source images to Topaz and returns base64 ou
|
||||
assert.ok(requestCapture.image instanceof File);
|
||||
assert.equal(result.data.data[0].b64_json, "BwcH");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
mockFetch(originalFetch);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1050,7 +1048,7 @@ test("handleImageGeneration polls NanoBanana task results and converts URLs to b
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls = [];
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
mockFetch(async (url, options = {}) => {
|
||||
const stringUrl = String(url);
|
||||
calls.push(stringUrl);
|
||||
|
||||
@@ -1078,7 +1076,7 @@ test("handleImageGeneration polls NanoBanana task results and converts URLs to b
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected URL: ${stringUrl}`);
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await handleImageGeneration({
|
||||
@@ -1099,7 +1097,7 @@ test("handleImageGeneration polls NanoBanana task results and converts URLs to b
|
||||
]);
|
||||
assert.deepEqual(result.data.data, [{ b64_json: "AQIDBA==", revised_prompt: "banana async" }]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
mockFetch(originalFetch);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2193,7 +2191,7 @@ test("handleImageGeneration still downloads a public image_url whose DNS resolve
|
||||
const fetchedUrls = [];
|
||||
let requestCapture;
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
mockFetch(async (url, options = {}) => {
|
||||
const stringUrl = String(url);
|
||||
fetchedUrls.push(stringUrl);
|
||||
if (stringUrl === "https://cdn.example.com/public-input.png") {
|
||||
@@ -2210,7 +2208,7 @@ test("handleImageGeneration still downloads a public image_url whose DNS resolve
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${stringUrl}`);
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await handleImageGeneration({
|
||||
@@ -2229,6 +2227,6 @@ test("handleImageGeneration still downloads a public image_url whose DNS resolve
|
||||
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;
|
||||
mockFetch(originalFetch);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ const providerChatRoute =
|
||||
await import("../../src/app/api/v1/providers/[provider]/chat/completions/route.ts");
|
||||
const imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts");
|
||||
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
|
||||
const { setPinnedFetchTestOverride } = await import("../../src/shared/network/remoteImageFetch.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -72,6 +73,7 @@ function createCodexEditForm(
|
||||
|
||||
async function resetStorage() {
|
||||
globalThis.fetch = originalFetch;
|
||||
setPinnedFetchTestOverride(undefined);
|
||||
apiKeysDb.resetApiKeyState();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
@@ -120,6 +122,7 @@ test.beforeEach(async () => {
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
setPinnedFetchTestOverride(undefined);
|
||||
apiKeysDb.resetApiKeyState();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
@@ -234,7 +237,7 @@ test("v1 image models GET exposes current Codex image models and hides inactive
|
||||
test("v1 image generation POST accepts promptless requests for image-only models", async () => {
|
||||
await seedConnection("topaz", { apiKey: "topaz-key" });
|
||||
|
||||
globalThis.fetch = async (url, options: RequestInit = {}) => {
|
||||
const mockFetchImpl = async (url, options: RequestInit = {}) => {
|
||||
const stringUrl = String(url);
|
||||
if (stringUrl === "https://example.com/topaz-input.png") {
|
||||
return new Response(new Uint8Array([1, 2, 3]), {
|
||||
@@ -254,6 +257,11 @@ test("v1 image generation POST accepts promptless requests for image-only models
|
||||
|
||||
throw new Error(`Unexpected URL: ${stringUrl}`);
|
||||
};
|
||||
// #13883: resolveImageSource now sets `pinDns: true`, which pins the connection via a
|
||||
// real undici socket and would bypass this mocked globalThis.fetch — route it through
|
||||
// the test-only pinned-fetch override instead (src/shared/network/remoteImageFetch.ts).
|
||||
globalThis.fetch = mockFetchImpl;
|
||||
setPinnedFetchTestOverride(mockFetchImpl);
|
||||
|
||||
const response = await imageRoute.POST(
|
||||
new Request("http://localhost/api/v1/images/generations", {
|
||||
|
||||
@@ -34,6 +34,7 @@ import { handleImageUpscale } from "../../open-sse/handlers/imageUpscale.ts";
|
||||
import { handleStabilityImageUpscale } from "../../open-sse/handlers/imageUpscale/stability.ts";
|
||||
import { handleTopazImageUpscale } from "../../open-sse/handlers/imageUpscale/topaz.ts";
|
||||
import { IMAGE_PROVIDERS } from "../../open-sse/config/imageRegistry.ts";
|
||||
import { setPinnedFetchTestOverride } from "../../src/shared/network/remoteImageFetch.ts";
|
||||
|
||||
// ── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -758,10 +759,15 @@ for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png
|
||||
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) => {
|
||||
const mockFetchImpl = (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;
|
||||
// #13883: resolveUpscaleImageSource now sets `pinDns: true`, which pins the connection
|
||||
// via a real undici socket and would bypass this mocked globalThis.fetch — route it
|
||||
// through the test-only pinned-fetch override instead (src/shared/network/remoteImageFetch.ts).
|
||||
globalThis.fetch = mockFetchImpl;
|
||||
setPinnedFetchTestOverride(mockFetchImpl);
|
||||
|
||||
try {
|
||||
const source = await withPublicDns(() =>
|
||||
@@ -772,5 +778,6 @@ test("resolveUpscaleImageSource still downloads a public URL whose DNS resolves
|
||||
assert.deepEqual(fetchedUrls, ["https://cdn.example.com/public.png"]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
setPinnedFetchTestOverride(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import dns from "node:dns";
|
||||
|
||||
import { handleImageGeneration } from "../../open-sse/handlers/imageGeneration.ts";
|
||||
import { setPinnedFetchTestOverride } from "../../src/shared/network/remoteImageFetch.ts";
|
||||
|
||||
// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard
|
||||
// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts).
|
||||
@@ -93,7 +94,7 @@ test("handleImageGeneration(nanobanana): async submit+poll returns URL payload",
|
||||
test("handleImageGeneration(nanobanana): response_format=b64_json converts URL to b64", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
const mockFetchImpl = async (url) => {
|
||||
const u = String(url);
|
||||
|
||||
if (u.includes("/generate")) {
|
||||
@@ -123,6 +124,12 @@ test("handleImageGeneration(nanobanana): response_format=b64_json converts URL t
|
||||
|
||||
throw new Error(`Unexpected URL: ${u}`);
|
||||
};
|
||||
// #13883: resolveImageSource (used for the URL result → base64 conversion) now sets
|
||||
// `pinDns: true`, which pins the connection via a real undici socket and would bypass
|
||||
// this mocked globalThis.fetch — route it through the test-only pinned-fetch override
|
||||
// instead (src/shared/network/remoteImageFetch.ts).
|
||||
globalThis.fetch = mockFetchImpl;
|
||||
setPinnedFetchTestOverride(mockFetchImpl);
|
||||
|
||||
try {
|
||||
const result = await handleImageGeneration({
|
||||
@@ -140,6 +147,7 @@ test("handleImageGeneration(nanobanana): response_format=b64_json converts URL t
|
||||
assert.equal(result.data.data[0].b64_json, "iVBORw==");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
setPinnedFetchTestOverride(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
81
tests/unit/pindns-toctou-13883.test.ts
Normal file
81
tests/unit/pindns-toctou-13883.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import dns from "node:dns";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-pindns-13883-"));
|
||||
|
||||
// #13883 — security: pinDns off at the three new public-only image fetch sites let a
|
||||
// DNS-rebinding hostname (public at validation time, private at real connect time) bypass
|
||||
// the `guard: "public-only"` check, since an un-pinned fetch performs its own, independent
|
||||
// DNS resolution. `resolveImageSource` / `normalizeNanoBananaTaskResult` (imageGeneration.ts)
|
||||
// and `resolveUpscaleImageSource` (imageUpscale/shared.ts) now all set `pinDns: true`, which
|
||||
// closes the gap by binding the connection to the single validated DNS answer instead of
|
||||
// letting the transport re-resolve it — see `src/shared/network/dnsPinnedFetch.ts`.
|
||||
//
|
||||
// None of these three call sites expose a `lookup` injection point (they always use the
|
||||
// real resolver), so this regression guard resolves a fake hostname to a public-looking,
|
||||
// deliberately unreachable TEST-NET-3 address (RFC 5737 — never routed on the public
|
||||
// internet) and asserts each site's request goes out through the real pinned undici socket
|
||||
// (and therefore fails closed against that unreachable address) rather than through a
|
||||
// mocked `globalThis.fetch`. If a future change dropped `pinDns: true` at any of these
|
||||
// sites, the un-pinned `fetch()` call would hit the mock below instead — turning this red.
|
||||
|
||||
function withPublicDns<T>(run: () => Promise<T>): Promise<T> {
|
||||
const original = dns.promises.lookup;
|
||||
(dns.promises as { lookup: unknown }).lookup = (async (
|
||||
_hostname: string,
|
||||
options?: { all?: boolean }
|
||||
) => {
|
||||
const record = { address: "203.0.113.7", family: 4 }; // RFC 5737 TEST-NET-3: unreachable
|
||||
return options && options.all ? [record] : record;
|
||||
}) as typeof dns.promises.lookup;
|
||||
return run().finally(() => {
|
||||
(dns.promises as { lookup: unknown }).lookup = original;
|
||||
});
|
||||
}
|
||||
|
||||
const { resolveImageSource, normalizeNanoBananaTaskResult } =
|
||||
await import("../../open-sse/handlers/imageGeneration.ts");
|
||||
const { resolveUpscaleImageSource } =
|
||||
await import("../../open-sse/handlers/imageUpscale/shared.ts");
|
||||
|
||||
/** Runs `attempt` with a mocked `globalThis.fetch` that must never be reached when
|
||||
* `pinDns: true` is wired correctly, and confirms the call still fails closed (the pinned
|
||||
* connection targets an unreachable address instead of falling back to the mock). */
|
||||
async function assertPinnedNotMocked(attempt: () => Promise<unknown>): Promise<void> {
|
||||
let mockCalled = false;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
mockCalled = true;
|
||||
throw new Error("globalThis.fetch must not be reached when pinDns is active");
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await assert.rejects(() => withPublicDns(attempt));
|
||||
assert.equal(mockCalled, false, "pinDns must bypass globalThis.fetch, not call it");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
test("resolveImageSource (imageGeneration.ts) fetches through the real pinned socket, not a mocked fetch (#13883)", async () => {
|
||||
await assertPinnedNotMocked(() => resolveImageSource("https://rebind-13883.example.com/x.png"));
|
||||
});
|
||||
|
||||
test("normalizeNanoBananaTaskResult result-URL download fetches through the real pinned socket, not a mocked fetch (#13883)", async () => {
|
||||
const taskData = {
|
||||
response: { resultImageUrl: "https://rebind-13883.example.com/result.png" },
|
||||
};
|
||||
await assertPinnedNotMocked(() =>
|
||||
normalizeNanoBananaTaskResult(taskData, { response_format: "b64_json" }, null)
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveUpscaleImageSource (imageUpscale/shared.ts) fetches through the real pinned socket, not a mocked fetch (#13883)", async () => {
|
||||
await assertPinnedNotMocked(() =>
|
||||
resolveUpscaleImageSource("https://rebind-13883.example.com/source.png")
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user