From c9fc36ca14fca034a9295c10fb92c976d9eab7d1 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 27 Apr 2026 02:25:46 -0300 Subject: [PATCH] feat(network): add guarded remote image fetch utility 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. --- .env.example | 7 ++ open-sse/handlers/imageGeneration.ts | 29 ++---- src/app/api/keys/[id]/reveal/route.ts | 3 +- src/app/api/keys/[id]/route.ts | 9 +- src/app/api/keys/route.ts | 7 +- src/lib/guardrails/visionBridgeHelpers.ts | 12 +-- src/shared/network/remoteImageFetch.ts | 118 ++++++++++++++++++++++ tests/unit/remote-image-fetch.test.ts | 51 ++++++++++ 8 files changed, 200 insertions(+), 36 deletions(-) create mode 100644 src/shared/network/remoteImageFetch.ts create mode 100644 tests/unit/remote-image-fetch.test.ts diff --git a/.env.example b/.env.example index 5e229a62ec..be615356cb 100644 --- a/.env.example +++ b/.env.example @@ -110,6 +110,13 @@ REQUIRE_API_KEY=false # Default: false | Security risk if enabled on shared instances. ALLOW_API_KEY_REVEAL=false +# Shared secret for the internal Codex Responses WebSocket bridge. +# Used by: src/app/api/internal/codex-responses-ws/route.ts — authenticates +# bridge requests between the Electron/browser WS relay and OmniRoute. +# ⚠️ REQUIRED for production — if unset, all WS bridge requests are rejected. +# Generate: openssl rand -base64 32 +# OMNIROUTE_WS_BRIDGE_SECRET= + # Comma-separated API key IDs that skip request logging (GDPR/compliance). # Used by: src/lib/compliance/index.ts — suppresses logs for specific keys. # NO_LOG_API_KEY_IDS=key_abc123,key_def456 diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 5661f9b406..dd3d7bbff1 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -20,10 +20,7 @@ import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts"; import { mapImageSize } from "../translator/image/sizeMapper.ts"; import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts"; import { ChatGptWebExecutor } from "../executors/chatgpt-web.ts"; -import { - getChatGptImage, - findChatGptImageBySha256, -} from "../services/chatgptImageCache.ts"; +import { getChatGptImage, findChatGptImageBySha256 } from "../services/chatgptImageCache.ts"; import { createHash } from "node:crypto"; import { saveCallLog } from "@/lib/usageDb"; import { @@ -32,6 +29,7 @@ import { fetchComfyOutput, extractComfyOutputFiles, } from "../utils/comfyuiClient.ts"; +import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([ "black-forest-labs/FLUX.1-redux", @@ -625,8 +623,7 @@ async function handleChatGptWebImageGeneration({ // own limit for image-1 / dall-e-3) so a stray n=1000 doesn't pin the // executor for hours before the upstream HTTP timeout fires. const CHATGPT_WEB_IMAGE_N_MAX = 4; - const rawCount = - Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1; + const rawCount = Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1; if (rawCount > CHATGPT_WEB_IMAGE_N_MAX) { return saveImageErrorResult({ provider, @@ -1547,15 +1544,11 @@ async function resolveImageSource(source) { } if (isHttpUrl(trimmed)) { - const response = await fetch(trimmed); - if (!response.ok) { - throw new Error(`Failed to fetch image source (${response.status})`); - } - const buffer = Buffer.from(await response.arrayBuffer()); + const remoteImage = await fetchRemoteImage(trimmed); return { - buffer, - base64: buffer.toString("base64"), - contentType: response.headers.get("content-type") || "application/octet-stream", + buffer: remoteImage.buffer, + base64: remoteImage.buffer.toString("base64"), + contentType: remoteImage.contentType, }; } @@ -2437,12 +2430,8 @@ async function normalizeNanoBananaTaskResult(taskData, body, log) { if (urlCandidates.length > 0) { const firstUrl = urlCandidates[0]; - const resp = await fetch(firstUrl); - if (!resp.ok) { - throw new Error(`Failed to fetch NanoBanana result image URL (${resp.status})`); - } - const arrayBuffer = await resp.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString("base64"); + const remoteImage = await fetchRemoteImage(firstUrl); + const base64 = remoteImage.buffer.toString("base64"); return [{ b64_json: base64, revised_prompt: body.prompt }]; } } diff --git a/src/app/api/keys/[id]/reveal/route.ts b/src/app/api/keys/[id]/reveal/route.ts index d3d6545bae..489fa5e9cf 100644 --- a/src/app/api/keys/[id]/reveal/route.ts +++ b/src/app/api/keys/[id]/reveal/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getApiKeyById } from "@/lib/localDb"; import { isApiKeyRevealEnabled } from "@/lib/apiKeyExposure"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import * as log from "@/sse/utils/logger"; // GET /api/keys/[id]/reveal - Reveal full API key for explicit copy actions export async function GET(request, { params }) { @@ -22,7 +23,7 @@ export async function GET(request, { params }) { return NextResponse.json({ key: key.key }); } catch (error) { - console.log("Error revealing key:", error); + log.error("keys", "Error revealing key", error); return NextResponse.json({ error: "Failed to reveal key" }, { status: 500 }); } } diff --git a/src/app/api/keys/[id]/route.ts b/src/app/api/keys/[id]/route.ts index 493cc0094d..dd45d8b298 100644 --- a/src/app/api/keys/[id]/route.ts +++ b/src/app/api/keys/[id]/route.ts @@ -10,6 +10,7 @@ import { syncToCloud } from "@/lib/cloudSync"; import { updateKeyPermissionsSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import * as log from "@/sse/utils/logger"; // GET /api/keys/[id] - Get single API key export async function GET(request, { params }) { @@ -31,7 +32,7 @@ export async function GET(request, { params }) { key: keyValue ? keyValue.slice(0, 8) + "****" + keyValue.slice(-4) : null, }); } catch (error) { - console.log("Error fetching key:", error); + log.error("keys", "Error fetching key", error); return NextResponse.json({ error: "Failed to fetch key" }, { status: 500 }); } } @@ -103,7 +104,7 @@ export async function PATCH(request, { params }) { ...(accessSchedule !== undefined && { accessSchedule }), }); } catch (error) { - console.log("Error updating key permissions:", error); + log.error("keys", "Error updating key permissions", error); return NextResponse.json({ error: "Failed to update permissions" }, { status: 500 }); } } @@ -126,7 +127,7 @@ export async function DELETE(request, { params }) { return NextResponse.json({ message: "Key deleted successfully" }); } catch (error) { - console.log("Error deleting key:", error); + log.error("keys", "Error deleting key", error); return NextResponse.json({ error: "Failed to delete key" }, { status: 500 }); } } @@ -142,6 +143,6 @@ async function syncKeysToCloudIfEnabled() { const machineId = await getConsistentMachineId(); await syncToCloud(machineId); } catch (error) { - console.log("Error syncing keys to cloud:", error); + log.error("keys", "Error syncing keys to cloud", error); } } diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts index d401cc9766..533c433749 100644 --- a/src/app/api/keys/route.ts +++ b/src/app/api/keys/route.ts @@ -6,6 +6,7 @@ import { createKeySchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import * as log from "@/sse/utils/logger"; function parsePagination(request: Request) { const url = new URL(request.url); @@ -43,7 +44,7 @@ export async function GET(request: Request) { allowKeyReveal: isApiKeyRevealEnabled(), }); } catch (error) { - console.log("Error fetching keys:", error); + log.error("keys", "Error fetching keys", error); return NextResponse.json({ error: "Failed to fetch keys" }, { status: 500 }); } } @@ -84,7 +85,7 @@ export async function POST(request) { { status: 201 } ); } catch (error) { - console.log("Error creating key:", error); + log.error("keys", "Error creating key", error); return NextResponse.json({ error: "Failed to create key" }, { status: 500 }); } } @@ -100,6 +101,6 @@ async function syncKeysToCloudIfEnabled() { const machineId = await getConsistentMachineId(); await syncToCloud(machineId); } catch (error) { - console.log("Error syncing keys to cloud:", error); + log.error("keys", "Error syncing keys to cloud", error); } } diff --git a/src/lib/guardrails/visionBridgeHelpers.ts b/src/lib/guardrails/visionBridgeHelpers.ts index d82cdb334e..32fd9bf151 100644 --- a/src/lib/guardrails/visionBridgeHelpers.ts +++ b/src/lib/guardrails/visionBridgeHelpers.ts @@ -1,6 +1,7 @@ /** * Vision Bridge helper functions for image processing. */ +import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; /** * Provider to environment variable mapping for API key resolution. @@ -111,14 +112,9 @@ export function resolveImageAsDataUri(imageUrl: string): string { } async function fetchRemoteImageAsDataUri(imageUrl: string, signal: AbortSignal): Promise { - const response = await fetch(imageUrl, { signal }); - if (!response.ok) { - throw new Error(`Vision image fetch error ${response.status}`); - } - - const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim() || "image/png"; - const bytes = Buffer.from(await response.arrayBuffer()); - return `data:${mediaType};base64,${bytes.toString("base64")}`; + const remoteImage = await fetchRemoteImage(imageUrl, { signal }); + const mediaType = remoteImage.contentType.split(";")[0]?.trim() || "image/png"; + return `data:${mediaType};base64,${remoteImage.buffer.toString("base64")}`; } async function normalizeVisionImageInput( diff --git a/src/shared/network/remoteImageFetch.ts b/src/shared/network/remoteImageFetch.ts new file mode 100644 index 0000000000..15d553ce8b --- /dev/null +++ b/src/shared/network/remoteImageFetch.ts @@ -0,0 +1,118 @@ +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 { + 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`); +} diff --git a/tests/unit/remote-image-fetch.test.ts b/tests/unit/remote-image-fetch.test.ts new file mode 100644 index 0000000000..56f7b80e1a --- /dev/null +++ b/tests/unit/remote-image-fetch.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; + +test("fetchRemoteImage reads public image bytes", 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", + }); + + assert.equal(result.buffer.toString("base64"), "AQID"); + assert.equal(result.contentType, "image/png"); +}); + +test("fetchRemoteImage blocks private image hosts before fetch", async () => { + let called = false; + + await assert.rejects( + () => + fetchRemoteImage("http://127.0.0.1:20128/private.png", { + fetchImpl: async () => { + called = true; + return new Response("unexpected"); + }, + guard: "public-only", + }), + /Blocked private or local provider URL/ + ); + + assert.equal(called, false); +}); + +test("fetchRemoteImage blocks redirects to private image hosts", async () => { + await assert.rejects( + () => + fetchRemoteImage("https://cdn.example.com/redirect.png", { + fetchImpl: async () => + new Response(null, { + status: 302, + headers: { location: "http://169.254.169.254/latest/meta-data" }, + }), + guard: "public-only", + }), + /Blocked private or local provider URL/ + ); +});