mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
Centralize remote image downloads behind a shared helper that validates outbound URLs, enforces redirect and size limits, and applies request timeouts before bytes are read. Wire the helper into image generation and vision bridge flows so remote image inputs and result URLs follow the same fetch policy and block redirects to private hosts. Update key management routes to use structured logging and document the WebSocket bridge secret in the example environment file.
119 lines
3.7 KiB
TypeScript
119 lines
3.7 KiB
TypeScript
import {
|
|
type OutboundUrlGuardMode,
|
|
getProviderOutboundGuard,
|
|
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;
|
|
|
|
export interface RemoteImageFetchOptions {
|
|
fetchImpl?: typeof fetch;
|
|
guard?: OutboundUrlGuardMode;
|
|
maxBytes?: number;
|
|
maxRedirects?: number;
|
|
signal?: AbortSignal;
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
export interface RemoteImageFetchResult {
|
|
buffer: Buffer;
|
|
contentType: string;
|
|
url: string;
|
|
}
|
|
|
|
function validateRemoteImageUrl(input: string | URL, guard: OutboundUrlGuardMode) {
|
|
return guard === "public-only" ? parseAndValidatePublicUrl(input) : parseOutboundUrl(input);
|
|
}
|
|
|
|
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);
|
|
|
|
let currentUrl = validateRemoteImageUrl(input, guard);
|
|
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
|
|
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`);
|
|
}
|