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/ + ); +});