ai_api / src /shared /network /remoteImageFetch.ts
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
6.36 kB
import { isIP } from "node:net";
import dns from "node:dns";
import {
type OutboundUrlGuardMode,
getProviderOutboundGuard,
isPrivateHost,
parseAndValidatePublicUrl,
parseOutboundUrl,
} from "@/shared/network/outboundUrlGuard";
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 {
buffer: Buffer;
contentType: string;
url: string;
}
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)");
}
}
}
function combineSignals(signal: AbortSignal | undefined, timeoutMs: number) {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
if (!signal) return timeoutSignal;
return AbortSignal.any([signal, timeoutSignal]);
}
async function readResponseBuffer(response: Response, maxBytes: number) {
const contentLengthHeader = response.headers.get("content-length");
const contentLength = contentLengthHeader ? Number.parseInt(contentLengthHeader, 10) : null;
if (contentLength !== null && Number.isFinite(contentLength) && contentLength > maxBytes) {
throw new Error(`Remote image exceeds ${maxBytes} byte limit`);
}
if (!response.body) {
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > maxBytes) {
throw new Error(`Remote image exceeds ${maxBytes} byte limit`);
}
return buffer;
}
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
totalBytes += chunk.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel();
throw new Error(`Remote image exceeds ${maxBytes} byte limit`);
}
chunks.push(chunk);
}
} finally {
reader.releaseLock();
}
return Buffer.concat(chunks, totalBytes);
}
export async function fetchRemoteImage(
input: string | URL,
options: RemoteImageFetchOptions = {}
): Promise<RemoteImageFetchResult> {
const fetchImpl = options.fetchImpl ?? fetch;
const guard = options.guard ?? getProviderOutboundGuard();
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",
signal,
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) {
throw new Error(`Remote image redirect missing Location header (${response.status})`);
}
if (redirectCount >= maxRedirects) {
throw new Error(`Remote image exceeded ${maxRedirects} redirect limit`);
}
currentUrl = validateRemoteImageUrl(new URL(location, currentUrl), guard);
continue;
}
if (!response.ok) {
throw new Error(`Remote image fetch error ${response.status}`);
}
return {
buffer: await readResponseBuffer(response, maxBytes),
contentType: response.headers.get("content-type") || "application/octet-stream",
url: currentUrl.toString(),
};
}
throw new Error(`Remote image exceeded ${maxRedirects} redirect limit`);
}