diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 1694333..41e8e9e 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -180,11 +180,15 @@ export async function fetchProxyHealth(): Promise { } export async function fetchDynamicModels(baseUrl: string, apiKey?: string): Promise { - let url = `${API_BASE}/models/discover?baseUrl=${encodeURIComponent(baseUrl)}`; + const body: { baseUrl: string; apiKey?: string } = { baseUrl }; if (apiKey) { - url += `&apiKey=${encodeURIComponent(apiKey)}`; + body.apiKey = apiKey; } - const response = await fetch(url, { headers: getAuthHeaders() }); + const response = await fetch(`${API_BASE}/models/discover`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify(body), + }); const data = await handleResponse<{ models: string[] }>(response); return data.models; } diff --git a/package-lock.json b/package-lock.json index adf45c3..f4efd5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1067,9 +1067,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" @@ -1729,6 +1729,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2098,6 +2099,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2821,6 +2823,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3096,9 +3099,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastify": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.2.tgz", - "integrity": "sha512-dBJolW+hm6N/yJVf6J5E1BxOBNkuXNl405nrfeR8SpvGWG3aCC2XDHyiFBdow8Win1kj7sjawQc257JlYY6M/A==", + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", "funding": [ { "type": "github", @@ -4075,6 +4078,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5080,6 +5084,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5153,6 +5158,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -5666,6 +5672,7 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", diff --git a/proxy/package-lock.json b/proxy/package-lock.json index f319f92..111b337 100644 --- a/proxy/package-lock.json +++ b/proxy/package-lock.json @@ -1490,6 +1490,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1833,6 +1834,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2743,6 +2745,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3042,9 +3045,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastify": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.2.tgz", - "integrity": "sha512-dBJolW+hm6N/yJVf6J5E1BxOBNkuXNl405nrfeR8SpvGWG3aCC2XDHyiFBdow8Win1kj7sjawQc257JlYY6M/A==", + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", "funding": [ { "type": "github", @@ -3954,6 +3957,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4791,6 +4795,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4865,6 +4870,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -5004,6 +5010,7 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", diff --git a/proxy/src/routes/proxy.ts b/proxy/src/routes/proxy.ts index 15fd985..bd4d93c 100644 --- a/proxy/src/routes/proxy.ts +++ b/proxy/src/routes/proxy.ts @@ -73,12 +73,14 @@ export function registerProxyRoutes( keyId = keySelection.keyId; } + const FORWARDED_HEADERS = ['content-type', 'accept', 'user-agent']; const headers: Record = {}; - for (const [key, value] of Object.entries(req.headers)) { + for (const name of FORWARDED_HEADERS) { + const value = req.headers[name]; if (typeof value === 'string') { - headers[key] = value; + headers[name] = value; } else if (Array.isArray(value)) { - headers[key] = value[0]; + headers[name] = value[0]; } } diff --git a/src/bots/templates.ts b/src/bots/templates.ts index 921eec3..6936bf0 100644 --- a/src/bots/templates.ts +++ b/src/bots/templates.ts @@ -6,6 +6,7 @@ import { mkdirSync, writeFileSync, chmodSync, chownSync, rmSync } from 'node:fs'; import { join } from 'node:path'; +import { validateHostname } from '../secrets/manager.js'; /** * Try to change file ownership, but gracefully skip if not permitted. @@ -15,14 +16,19 @@ function tryChown(path: string, uid: number, gid: number): void { try { chownSync(path, uid, gid); } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'EPERM') { - // Not running as root - skip chown (acceptable in dev/CI) - return; - } + if ((err as NodeJS.ErrnoException).code === 'EPERM') return; throw err; } } +const OPENCLAW_UID = 1000; +const OPENCLAW_GID = 1000; + +function setOwnership(path: string, mode: number): void { + chmodSync(path, mode); + tryChown(path, OPENCLAW_UID, OPENCLAW_GID); +} + export interface BotPersona { name: string; identity: string; @@ -194,51 +200,25 @@ function generateIdentityMd(persona: BotPersona): string { export function createBotWorkspace(dataDir: string, config: BotWorkspaceConfig): void { const botDir = join(dataDir, 'bots', config.botHostname); const workspaceDir = join(botDir, 'workspace'); + const agentDir = join(botDir, 'agents', 'main', 'agent'); + const sessionsDir = join(botDir, 'agents', 'main', 'sessions'); + const sandboxDir = join(botDir, 'sandbox'); - // Create directories with permissions for bot container (runs as uid 1000) - mkdirSync(botDir, { recursive: true, mode: 0o777 }); - mkdirSync(workspaceDir, { recursive: true, mode: 0o777 }); - // Ensure parent dir has correct permissions (recursive: true doesn't set mode on existing dirs) - chmodSync(botDir, 0o777); - chmodSync(workspaceDir, 0o777); + for (const dir of [botDir, workspaceDir, agentDir, sessionsDir, sandboxDir]) { + mkdirSync(dir, { recursive: true, mode: 0o755 }); + setOwnership(dir, 0o755); + } - // Write openclaw.json at root of bot directory (OPENCLAW_STATE_DIR) - const openclawConfig = generateOpenclawConfig(config); const configPath = join(botDir, 'openclaw.json'); - writeFileSync(configPath, JSON.stringify(openclawConfig, null, 2)); - chmodSync(configPath, 0o666); + writeFileSync(configPath, JSON.stringify(generateOpenclawConfig(config), null, 2)); + setOwnership(configPath, 0o644); - // Write only persona files — OpenClaw's ensureAgentWorkspace() will create - // AGENTS.md, BOOTSTRAP.md, TOOLS.md, HEARTBEAT.md from its own templates - // (using writeFileIfMissing / wx flag, so our files won't be overwritten). const soulPath = join(workspaceDir, 'SOUL.md'); const identityPath = join(workspaceDir, 'IDENTITY.md'); writeFileSync(soulPath, generateSoulMd(config.persona)); writeFileSync(identityPath, generateIdentityMd(config.persona)); - chmodSync(soulPath, 0o666); - chmodSync(identityPath, 0o666); - - // OpenClaw runs as uid 1000 (node user), so we need to set ownership - const OPENCLAW_UID = 1000; - const OPENCLAW_GID = 1000; - - const agentDir = join(botDir, 'agents', 'main', 'agent'); - mkdirSync(agentDir, { recursive: true, mode: 0o777 }); - chmodSync(agentDir, 0o777); - tryChown(agentDir, OPENCLAW_UID, OPENCLAW_GID); - - // Pre-create sessions directory for OpenClaw runtime use - const sessionsDir = join(botDir, 'agents', 'main', 'sessions'); - mkdirSync(sessionsDir, { recursive: true, mode: 0o777 }); - chmodSync(sessionsDir, 0o777); - tryChown(sessionsDir, OPENCLAW_UID, OPENCLAW_GID); - - // Pre-create sandbox directory for OpenClaw code execution - // OpenClaw hardcodes /app/workspace for sandbox operations - const sandboxDir = join(botDir, 'sandbox'); - mkdirSync(sandboxDir, { recursive: true, mode: 0o777 }); - chmodSync(sandboxDir, 0o777); - tryChown(sandboxDir, OPENCLAW_UID, OPENCLAW_GID); + setOwnership(soulPath, 0o644); + setOwnership(identityPath, 0o644); } /** @@ -260,6 +240,7 @@ export function getBotWorkspacePath(dataDir: string, hostname: string): string { * @param hostname - Bot hostname */ export function deleteBotWorkspace(dataDir: string, hostname: string): void { + validateHostname(hostname); const botDir = join(dataDir, 'bots', hostname); rmSync(botDir, { recursive: true, force: true }); } diff --git a/src/secrets/manager.ts b/src/secrets/manager.ts index bee5fc9..4f9caa1 100644 --- a/src/secrets/manager.ts +++ b/src/secrets/manager.ts @@ -8,8 +8,8 @@ import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -/** Hostname regex for validation - prevents directory traversal attacks */ const HOSTNAME_REGEX = /^[a-z0-9-]{1,64}$/; +const SECRET_NAME_REGEX = /^[A-Z0-9_]{1,64}$/; /** * Returns the root directory for secrets storage. @@ -64,7 +64,10 @@ export function createBotSecretsDir(hostname: string): string { export function writeSecret(hostname: string, name: string, value: string): void { validateHostname(hostname); - // Ensure bot directory exists + if (!SECRET_NAME_REGEX.test(name)) { + throw new Error(`Invalid secret name: ${name}`); + } + const botDir = createBotSecretsDir(hostname); const filePath = join(botDir, name); @@ -82,6 +85,10 @@ export function writeSecret(hostname: string, name: string, value: string): void export function readSecret(hostname: string, name: string): string | undefined { validateHostname(hostname); + if (!SECRET_NAME_REGEX.test(name)) { + throw new Error(`Invalid secret name: ${name}`); + } + const secretsRoot = getSecretsRoot(); const filePath = join(secretsRoot, hostname, name); diff --git a/src/server.ts b/src/server.ts index 844cafc..b209820 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,6 +5,7 @@ import fastifyHelmet from '@fastify/helmet'; import { join, resolve } from 'node:path'; import { existsSync } from 'node:fs'; import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { promises as dns } from 'node:dns'; import { getConfig } from './config.js'; import { initDb } from './db/index.js'; @@ -114,6 +115,132 @@ function toDockerHostUrl(url: string): string { return url.replace(/\blocalhost\b|127\.0\.0\.1/g, 'host.docker.internal'); } +const VALID_PROVIDER_IDS = new Set([ + 'openai', 'anthropic', 'google', 'venice', 'openrouter', 'ollama', 'grok', + 'deepseek', 'mistral', 'groq', 'cerebras', 'fireworks', 'togetherai', + 'deepinfra', 'perplexity', 'nvidia', 'minimax', 'moonshot', 'scaleway', + 'nebius', 'ovhcloud', 'huggingface', +]); + +const VALID_CHANNEL_TYPES = new Set(['telegram', 'discord']); + +const MODEL_REGEX = /^[a-zA-Z0-9._:/-]{1,128}$/; + +function isPrivateIp(ip: string): boolean { + // Handle IPv6-mapped IPv4 (e.g., ::ffff:127.0.0.1) + const v4Mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(ip); + const normalizedIp = v4Mapped ? v4Mapped[1] : ip; + + if (normalizedIp === '::1') return true; + + const lowerIp = normalizedIp.toLowerCase(); + if (lowerIp.startsWith('fe80:')) return true; + if (lowerIp.startsWith('fc') || lowerIp.startsWith('fd')) return true; + + const ipv4Match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(normalizedIp); + if (ipv4Match) { + const [, a, b, c, d] = ipv4Match.map(Number); + if (a === 10) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 169 && b === 254) return true; + if (a === 127) return true; + if (a === 0) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + if (a === 198 && b >= 18 && b <= 19) return true; + if (a === 240 || (a === 255 && b === 255 && c === 255 && d === 255)) return true; + } + + return false; +} + +function isPrivateUrl(urlStr: string): boolean { + let parsed: URL; + try { + parsed = new URL(urlStr); + } catch { + return true; + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + return true; + } + + const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + + if ( + hostname === 'localhost' || + hostname === '0.0.0.0' || + hostname.endsWith('.local') || + hostname.endsWith('.internal') + ) { + return true; + } + + return isPrivateIp(hostname); +} + +const MAX_RESPONSE_BODY_BYTES = 1024 * 1024; // 1MB + +async function readLimitedBody(response: Response, maxBytes: number): Promise { + const body = response.body; + if (!body) throw new Error('No response body'); + const reader = body.getReader(); + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + for (;;) { + const result = await reader.read(); + if (result.done) break; + const value = result.value as Uint8Array; + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + void reader.cancel(); + throw new Error('Response body exceeds size limit'); + } + chunks.push(value); + } + + const combined = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(combined); +} + +async function resolveAndValidateUrl(urlStr: string): Promise<{ resolvedUrl: string; originalHost: string }> { + const parsed = new URL(urlStr); + const hostname = parsed.hostname.replace(/^\[|\]$/g, ''); + + // If hostname is already an IP literal, isPrivateUrl already checked it + if (/^[\d.]+$/.test(hostname) || hostname.includes(':')) { + return { resolvedUrl: urlStr, originalHost: hostname }; + } + + const results4 = await dns.resolve4(hostname).catch(() => [] as string[]); + const results6 = await dns.resolve6(hostname).catch(() => [] as string[]); + const addresses = [...results4, ...results6]; + + if (addresses.length === 0) { + throw new Error('DNS resolution returned no addresses'); + } + + for (const addr of addresses) { + if (isPrivateIp(addr)) { + throw new Error('Resolved address is private'); + } + } + + // Pin to the first resolved IP to prevent DNS rebinding + const pinnedIp = addresses[0]; + const pinnedHost = pinnedIp.includes(':') ? `[${pinnedIp}]` : pinnedIp; + parsed.hostname = pinnedHost; + return { resolvedUrl: parsed.toString(), originalHost: hostname }; +} + async function resolveHostPaths(config: ReturnType): Promise<{ hostDataDir: string; hostSecretsDir: string; @@ -146,7 +273,18 @@ export async function buildServer(): Promise { // Register security headers await server.register(fastifyHelmet, { - contentSecurityPolicy: false, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:"], + connectSrc: ["'self'"], + fontSrc: ["'self'"], + objectSrc: ["'none'"], + frameAncestors: ["'none'"], + }, + }, }); // Register rate limiting @@ -265,6 +403,11 @@ export async function buildServer(): Promise { return { error: 'Missing required field: name' }; } + if (!/^[a-zA-Z0-9 _.-]{1,128}$/.test(body.name)) { + reply.code(400); + return { error: 'Bot name must be 1-128 characters: letters, numbers, spaces, underscores, dots, hyphens' }; + } + if (!body.hostname) { reply.code(400); return { error: 'Missing required field: hostname' }; @@ -286,6 +429,24 @@ export async function buildServer(): Promise { return { error: 'At least one channel is required' }; } + for (const provider of body.providers) { + if (!VALID_PROVIDER_IDS.has(provider.providerId)) { + reply.code(400); + return { error: `Invalid provider: ${provider.providerId}` }; + } + if (!MODEL_REGEX.test(provider.model)) { + reply.code(400); + return { error: `Invalid model name: ${provider.model}` }; + } + } + + for (const channel of body.channels) { + if (!VALID_CHANNEL_TYPES.has(channel.channelType)) { + reply.code(400); + return { error: `Invalid channel type: ${channel.channelType}` }; + } + } + // Check for duplicate hostname if (getBotByHostname(body.hostname)) { reply.code(409); @@ -377,15 +538,6 @@ export async function buildServer(): Promise { `PORT=${port}`, ]; - // Add channel tokens - for (const channel of body.channels) { - if (channel.channelType === 'telegram') { - environment.push(`TELEGRAM_BOT_TOKEN=${channel.token}`); - } else if (channel.channelType === 'discord') { - environment.push(`DISCORD_TOKEN=${channel.token}`); - } - } - const containerId = await docker.createContainer(bot.hostname, bot.id, { image: config.openclawImage, environment, @@ -611,41 +763,51 @@ export async function buildServer(): Promise { } }); - // Dynamic model discovery for any OpenAI-compatible provider (e.g., Ollama) - // Fetches from the provider's /v1/models endpoint. - server.get<{ Querystring: { baseUrl?: string; apiKey?: string } }>('/api/models/discover', async (request, reply) => { - const baseUrl = request.query.baseUrl; + server.post<{ Body: { baseUrl?: string; apiKey?: string } }>('/api/models/discover', async (request, reply) => { + const baseUrl = request.body.baseUrl; if (!baseUrl) { reply.code(400); - return { error: 'Missing baseUrl query parameter' }; + return { error: 'Missing baseUrl in request body' }; } + if (isPrivateUrl(baseUrl)) { + reply.code(400); + return { error: 'Requests to private/internal addresses are not allowed' }; + } + + const fetchBase = toDockerHostUrl(baseUrl); + + if (isPrivateUrl(fetchBase)) { + reply.code(400); + return { error: 'Requests to private/internal addresses are not allowed' }; + } + + const url = fetchBase.replace(/\/+$/, '') + '/models'; + const controller = new AbortController(); + const timeout = setTimeout(() => { controller.abort(); }, 5000); + try { - // Translate localhost → host.docker.internal for fetches from inside Docker - const fetchBase = toDockerHostUrl(baseUrl); - // Append /models to the base URL, preserving path (e.g. /v1 → /v1/models) - const url = fetchBase.replace(/\/+$/, '') + '/models'; - const controller = new AbortController(); - const timeout = setTimeout(() => { controller.abort(); }, 5000); - - const headers: Record = {}; - if (request.query.apiKey) { - headers.Authorization = `Bearer ${request.query.apiKey}`; + const { resolvedUrl, originalHost } = await resolveAndValidateUrl(url); + + const headers: Record = { Host: originalHost }; + if (request.body.apiKey) { + headers.Authorization = `Bearer ${request.body.apiKey}`; } - const response = await fetch(url, { signal: controller.signal, headers }); - clearTimeout(timeout); + const response = await fetch(resolvedUrl, { signal: controller.signal, headers }); if (!response.ok) { return { models: [] }; } - const data = await response.json() as { data?: { id: string }[] }; + const bodyText = await readLimitedBody(response, MAX_RESPONSE_BODY_BYTES); + const data = JSON.parse(bodyText) as { data?: { id: string }[] }; const models = (data.data ?? []).map((m: { id: string }) => m.id); return { models }; } catch { - // Connection refused, timeout, etc. — graceful fallback return { models: [] }; + } finally { + clearTimeout(timeout); } });