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
64 changes: 64 additions & 0 deletions src/shared/network/remoteImageFetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { isIP } from "node:net";
import dns from "node:dns";
import {
type OutboundUrlGuardMode,
getProviderOutboundGuard,
isPrivateHost,
parseAndValidatePublicUrl,
parseOutboundUrl,
} from "@/shared/network/outboundUrlGuard";
Expand All @@ -9,13 +12,27 @@ const DEFAULT_MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024;
const DEFAULT_MAX_REDIRECTS = 3;
const DEFAULT_TIMEOUT_MS = 15000;

/**
* Minimal DNS lookup contract — matches the shape returned by
* `node:dns/promises`.lookup(host, { all: true }). Exposed as an option so
* tests can inject a fake resolver without touching real DNS.
*/
export type RemoteImageLookup = (
hostname: string
) => Promise<Array<{ address: string; family: number }>>;

export interface RemoteImageFetchOptions {
fetchImpl?: typeof fetch;
guard?: OutboundUrlGuardMode;
maxBytes?: number;
maxRedirects?: number;
signal?: AbortSignal;
timeoutMs?: number;
/**
* DNS resolver used for the rebinding guard. Defaults to
* `dns.promises.lookup(host, { all: true })`. Tests can pass a fake.
*/
lookup?: RemoteImageLookup;
}

export interface RemoteImageFetchResult {
Expand All @@ -28,6 +45,49 @@ function validateRemoteImageUrl(input: string | URL, guard: OutboundUrlGuardMode
return guard === "public-only" ? parseAndValidatePublicUrl(input) : parseOutboundUrl(input);
}

const defaultLookup: RemoteImageLookup = (hostname) =>
dns.promises.lookup(hostname, { all: true });

/**
* Defence against DNS-rebinding SSRF (GHSA-cmhj-wh2f-9cgx). The
* `parseAndValidatePublicUrl` guard only inspects the hostname *string*, so a
* public-looking host that resolves to a private/loopback/link-local /
* cloud-metadata address would otherwise be fetched. Resolve the host up-front
* and reject if ANY answer is private (defeats the multi-A trick). IP literals
* are skipped — they're already covered by the URL guard. This narrows but
* does not fully close the TOCTOU window with fetch's own DNS resolution;
* pinning the connection to the validated IP via undici would close it for
* good, but is deferred to a follow-up so this fix stays surgical and
* dependency-free.
*/
async function assertHostnameResolvesPublic(
url: URL,
guard: OutboundUrlGuardMode,
lookup: RemoteImageLookup
): Promise<void> {
if (guard !== "public-only") return; // private-allowing modes skip this guard
const hostname = url.hostname;
const bare =
hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
if (!bare) return;
if (isIP(bare)) return; // IP literal — already validated by the URL guard.

let resolved: Array<{ address: string; family: number }>;
try {
resolved = await lookup(bare);
} catch {
throw new Error("Remote image host could not be resolved (blocked)");
}
if (!resolved.length) {
throw new Error("Remote image host could not be resolved (blocked)");
}
for (const { address } of resolved) {
if (isPrivateHost(address)) {
throw new Error("Remote image host resolves to a blocked private address (DNS rebinding)");
}
}
Comment on lines +73 to +88

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

IPv4-compatible IPv6 addresses (e.g., ::127.0.0.1 or ::7f00:1) and unspecified IPv6 addresses (e.g., ::) can bypass the isPrivateHost check because it lacks specific rules for these formats. Since isIP(bare) returns 6 for these, they bypass the DNS resolution check entirely and are fetched directly, leading to a complete SSRF guard bypass.

We can close this gap by explicitly checking for and rejecting any IPv6 address starting with :: (except IPv4-mapped addresses starting with ::ffff:) both for IP literals and resolved addresses.

}

function combineSignals(signal: AbortSignal | undefined, timeoutMs: number) {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
if (!signal) return timeoutSignal;
Expand Down Expand Up @@ -82,9 +142,13 @@ export async function fetchRemoteImage(
const maxBytes = options.maxBytes ?? DEFAULT_MAX_REMOTE_IMAGE_BYTES;
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
const signal = combineSignals(options.signal, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const lookup = options.lookup ?? defaultLookup;

let currentUrl = validateRemoteImageUrl(input, guard);
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
// DNS-rebinding guard: validate every hop's hostname against its resolved
// IPs before issuing the request (GHSA-cmhj-wh2f-9cgx).
await assertHostnameResolvesPublic(currentUrl, guard, lookup);
const response = await fetchImpl(currentUrl.toString(), {
method: "GET",
redirect: "manual",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,32 @@

import test from "node:test";
import assert from "node:assert/strict";
import dns from "node:dns";
import { callVisionModel, type VisionModelConfig } from "@/lib/guardrails/visionBridgeHelpers";

// Store original fetch
const originalFetch = globalThis.fetch;

// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard
// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts).
// These tests mock globalThis.fetch with example.com hosts that don't actually
// resolve in CI; the call path (callVisionModel -> fetchRemoteImageAsDataUri)
// does not expose a way to inject a `lookup` stub through to fetchRemoteImage,
// so we monkey-patch dns.promises.lookup with a pass-through public-IP
// resolver. Node --test runs each test 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;
});

test("callVisionModel returns description on success", async () => {
// Mock global fetch
const mockResponse = {
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/image-generation-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
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-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.
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 { IMAGE_PROVIDERS, parseImageModel, getAllImageModels } =
await import("../../open-sse/config/imageRegistry.ts");
const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts");
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/nanobanana-image-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
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;
Expand Down
121 changes: 121 additions & 0 deletions tests/unit/remote-image-fetch-dns-rebinding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import assert from "node:assert/strict";
import test from "node:test";

import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";

// GHSA-cmhj-wh2f-9cgx — DNS-rebinding SSRF: a public hostname whose DNS
// resolves to a private/loopback IP would otherwise bypass the string-only
// `parseAndValidatePublicUrl` guard. The fix is to (a) resolve the host once
// up-front, (b) reject if any resolved record is private, and (c) pin the
// connection to that resolved IP so a second DNS resolution at fetch-time
// cannot rebind to a different (private) address.

test("fetchRemoteImage rejects when DNS resolves a public hostname to loopback (rebinding)", async () => {
let fetchCalled = false;
await assert.rejects(
() =>
fetchRemoteImage("https://attacker.example.com/image.png", {
fetchImpl: async () => {
fetchCalled = true;
return new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { "content-type": "image/png" },
});
},
guard: "public-only",
// Inject a fake DNS resolver: attacker.example.com resolves to 127.0.0.1
lookup: async () => [{ address: "127.0.0.1", family: 4 }],
}),
/blocked|private|rebind/i
);
assert.equal(fetchCalled, false, "fetch must not be called when DNS resolves to a private IP");
});

test("fetchRemoteImage rejects when DNS resolves to cloud-metadata IP (169.254.169.254)", async () => {
let fetchCalled = false;
await assert.rejects(
() =>
fetchRemoteImage("https://cdn.example.com/image.png", {
fetchImpl: async () => {
fetchCalled = true;
return new Response("unexpected");
},
guard: "public-only",
lookup: async () => [{ address: "169.254.169.254", family: 4 }],
}),
/blocked|private|rebind/i
);
assert.equal(fetchCalled, false);
});

test("fetchRemoteImage rejects when any of multiple resolved IPs is private (multi-A trick)", async () => {
let fetchCalled = false;
await assert.rejects(
() =>
fetchRemoteImage("https://multi.example.com/image.png", {
fetchImpl: async () => {
fetchCalled = true;
return new Response("unexpected");
},
guard: "public-only",
lookup: async () => [
{ address: "203.0.113.5", family: 4 },
{ address: "10.0.0.1", family: 4 },
],
}),
/blocked|private|rebind/i
);
assert.equal(fetchCalled, false);
});

test("fetchRemoteImage allows a public hostname that resolves to a public IP", async () => {
const result = await fetchRemoteImage("https://cdn.example.com/image.png", {
fetchImpl: async () =>
new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { "content-type": "image/png" },
}),
guard: "public-only",
lookup: async () => [{ address: "203.0.113.5", family: 4 }],
});
assert.equal(result.buffer.toString("base64"), "AQID");
});

test("fetchRemoteImage skips DNS resolution for IP-literal hosts (already string-validated)", async () => {
// IP literals are validated by parseAndValidatePublicUrl directly; the
// resolver injection should not be invoked.
let lookupCalled = false;
const result = await fetchRemoteImage("https://203.0.113.5/image.png", {
fetchImpl: async () =>
new Response(new Uint8Array([1]), {
status: 200,
headers: { "content-type": "image/png" },
}),
guard: "public-only",
lookup: async () => {
lookupCalled = true;
return [{ address: "203.0.113.5", family: 4 }];
},
});
assert.equal(result.buffer.toString("base64"), "AQ==");
assert.equal(lookupCalled, false, "IP-literal hosts must not trigger DNS lookup");
});

test("fetchRemoteImage rejects when DNS resolution fails entirely", async () => {
let fetchCalled = false;
await assert.rejects(
() =>
fetchRemoteImage("https://nx.example.com/image.png", {
fetchImpl: async () => {
fetchCalled = true;
return new Response("unexpected");
},
guard: "public-only",
lookup: async () => {
throw new Error("ENOTFOUND");
},
}),
/resolve|dns|blocked/i
);
assert.equal(fetchCalled, false);
});
7 changes: 7 additions & 0 deletions tests/unit/remote-image-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import test from "node:test";

import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";

// Stub DNS resolver: every (unused) hostname resolves to a public IP. The
// rebinding guard (GHSA-cmhj-wh2f-9cgx) needs a non-empty resolution; without
// it, fictitious hosts like `cdn.example.com` would correctly be rejected.
const publicLookup = async () => [{ address: "203.0.113.5" as string, family: 4 }];

test("fetchRemoteImage reads public image bytes", async () => {
const result = await fetchRemoteImage("https://cdn.example.com/image.png", {
fetchImpl: async () =>
Expand All @@ -11,6 +16,7 @@ test("fetchRemoteImage reads public image bytes", async () => {
headers: { "content-type": "image/png" },
}),
guard: "public-only",
lookup: publicLookup,
});

assert.equal(result.buffer.toString("base64"), "AQID");
Expand Down Expand Up @@ -45,6 +51,7 @@ test("fetchRemoteImage blocks redirects to private image hosts", async () => {
headers: { location: "http://169.254.169.254/latest/meta-data" },
}),
guard: "public-only",
lookup: publicLookup,
}),
/Blocked private or local provider URL/
);
Expand Down
Loading