diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index d6a9577..a8c9cc5 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -1,12 +1,12 @@ { "name": "botmaker-dashboard", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "botmaker-dashboard", - "version": "1.0.1", + "version": "1.0.2", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/dashboard/package.json b/dashboard/package.json index 977134c..1a53e0d 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1,7 +1,7 @@ { "name": "botmaker-dashboard", "private": true, - "version": "1.0.1", + "version": "1.0.2", "type": "module", "scripts": { "dev": "vite", 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..04bbdf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "botmaker", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "botmaker", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "dependencies": { "@fastify/basic-auth": "^6.0.0", @@ -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" @@ -3096,9 +3096,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", diff --git a/package.json b/package.json index f6c6b8e..9dab8c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "botmaker", - "version": "1.0.1", + "version": "1.0.2", "description": "Web UI for creating and managing OpenClaw bots", "author": "Jeff Garzik", "license": "MIT", diff --git a/proxy/package-lock.json b/proxy/package-lock.json index f319f92..ea17495 100644 --- a/proxy/package-lock.json +++ b/proxy/package-lock.json @@ -1,12 +1,12 @@ { "name": "keyring-proxy", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "keyring-proxy", - "version": "1.0.1", + "version": "1.0.2", "dependencies": { "@fastify/helmet": "^13.0.2", "@fastify/rate-limit": "^10.3.0", @@ -3042,9 +3042,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", diff --git a/proxy/package.json b/proxy/package.json index 2ec9eeb..7973f11 100644 --- a/proxy/package.json +++ b/proxy/package.json @@ -1,6 +1,6 @@ { "name": "keyring-proxy", - "version": "1.0.1", + "version": "1.0.2", "description": "HTTP forward proxy for BotMaker - validates bot tokens, injects API keys", "type": "module", "main": "dist/index.js", 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 2166148..5cbf688 100644 --- a/src/bots/templates.ts +++ b/src/bots/templates.ts @@ -6,23 +6,31 @@ 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. * chown requires root privileges; in CI/dev environments we may not have them. */ -function tryChown(path: string, uid: number, gid: number): void { +function tryChown(path: string, uid: number, gid: number): boolean { try { chownSync(path, uid, gid); + return true; } 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 false; throw err; } } +const OPENCLAW_UID = 1000; +const OPENCLAW_GID = 1000; + +function setOwnership(path: string, mode: number): void { + const owned = tryChown(path, OPENCLAW_UID, OPENCLAW_GID); + // If chown failed, widen permissions so container UID 1000 can still write + chmodSync(path, owned ? mode : mode | 0o022); +} + export interface BotPersona { name: string; identity: string; @@ -257,51 +265,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); } /** @@ -323,6 +305,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/discover.test.ts b/src/discover.test.ts new file mode 100644 index 0000000..1f3e620 --- /dev/null +++ b/src/discover.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { sessions } from './server.js'; + +interface ErrorResponse { + error: string; +} + +async function createTestServer(): Promise { + process.env.ADMIN_PASSWORD = 'test-password-long'; + vi.doMock('./services/DockerService.js', () => ({ + DockerService: class { + getContainerStatus = vi.fn().mockResolvedValue(null); + getAllContainerStats = vi.fn().mockResolvedValue([]); + getVolumeMountpoint = vi.fn().mockResolvedValue('/tmp/test'); + }, + })); + vi.doMock('./db/index.js', () => ({ + initDb: vi.fn(), + getDb: vi.fn().mockReturnValue({ transaction: vi.fn(() => vi.fn()) }), + })); + vi.doMock('./bots/store.js', () => ({ + listBots: vi.fn().mockReturnValue([]), + })); + vi.doMock('./services/ReconciliationService.js', () => ({ + ReconciliationService: class { + reconcileOnStartup = vi.fn().mockResolvedValue({ + orphanedContainers: [], + orphanedWorkspaces: [], + orphanedSecrets: [], + }); + }, + })); + + const { buildServer } = await import('./server.js'); + return buildServer(); +} + +async function getAuthToken(server: FastifyInstance): Promise { + const response = await server.inject({ + method: 'POST', + url: '/api/login', + payload: { password: 'test-password-long' }, + }); + const body = JSON.parse(response.body) as { token: string }; + return body.token; +} + +describe('/api/models/discover', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + sessions.clear(); + vi.resetModules(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + sessions.clear(); + vi.resetModules(); + }); + + it('should require authentication', async () => { + const server = await createTestServer(); + const response = await server.inject({ + method: 'POST', + url: '/api/models/discover', + payload: { baseUrl: 'https://api.openai.com/v1' }, + }); + expect(response.statusCode).toBe(401); + await server.close(); + }); + + it('should reject missing baseUrl', async () => { + const server = await createTestServer(); + const token = await getAuthToken(server); + const response = await server.inject({ + method: 'POST', + url: '/api/models/discover', + payload: {}, + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body) as ErrorResponse; + expect(body.error).toMatch(/baseUrl/i); + await server.close(); + }); + + it('should reject private IPv4 addresses (10.x)', async () => { + const server = await createTestServer(); + const token = await getAuthToken(server); + const response = await server.inject({ + method: 'POST', + url: '/api/models/discover', + payload: { baseUrl: 'http://10.0.0.1/v1' }, + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body) as ErrorResponse; + expect(body.error).toMatch(/private/i); + await server.close(); + }); + + it('should reject private IPv4 addresses (192.168.x)', async () => { + const server = await createTestServer(); + const token = await getAuthToken(server); + const response = await server.inject({ + method: 'POST', + url: '/api/models/discover', + payload: { baseUrl: 'http://192.168.1.1/v1' }, + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body) as ErrorResponse; + expect(body.error).toMatch(/private/i); + await server.close(); + }); + + it('should allow localhost for local discovery (Ollama)', async () => { + const server = await createTestServer(); + const token = await getAuthToken(server); + const response = await server.inject({ + method: 'POST', + url: '/api/models/discover', + payload: { baseUrl: 'http://localhost:11434/v1' }, + headers: { Authorization: `Bearer ${token}` }, + }); + // Allowed through SSRF check; fetch fails → returns empty models + expect(response.statusCode).toBe(200); + await server.close(); + }); + + it('should allow 127.0.0.1 for local discovery', async () => { + const server = await createTestServer(); + const token = await getAuthToken(server); + const response = await server.inject({ + method: 'POST', + url: '/api/models/discover', + payload: { baseUrl: 'http://127.0.0.1:11434/v1' }, + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.statusCode).toBe(200); + await server.close(); + }); + + it('should reject non-http protocols', async () => { + const server = await createTestServer(); + const token = await getAuthToken(server); + const response = await server.inject({ + method: 'POST', + url: '/api/models/discover', + payload: { baseUrl: 'ftp://example.com/v1' }, + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body) as ErrorResponse; + expect(body.error).toMatch(/private/i); + await server.close(); + }); + + it('should reject link-local IPv6', async () => { + const server = await createTestServer(); + const token = await getAuthToken(server); + const response = await server.inject({ + method: 'POST', + url: '/api/models/discover', + payload: { baseUrl: 'http://[fe80::1]/v1' }, + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body) as ErrorResponse; + expect(body.error).toMatch(/private/i); + await server.close(); + }); + + it('should reject IPv6 loopback', async () => { + const server = await createTestServer(); + const token = await getAuthToken(server); + const response = await server.inject({ + method: 'POST', + url: '/api/models/discover', + payload: { baseUrl: 'http://[::1]/v1' }, + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body) as ErrorResponse; + expect(body.error).toMatch(/private/i); + await server.close(); + }); + + it('should reject .local domains', async () => { + const server = await createTestServer(); + const token = await getAuthToken(server); + const response = await server.inject({ + method: 'POST', + url: '/api/models/discover', + payload: { baseUrl: 'http://myhost.local/v1' }, + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body) as ErrorResponse; + expect(body.error).toMatch(/private/i); + await server.close(); + }); + + it('should reject 172.16.x private range', async () => { + const server = await createTestServer(); + const token = await getAuthToken(server); + const response = await server.inject({ + method: 'POST', + url: '/api/models/discover', + payload: { baseUrl: 'http://172.16.0.1/v1' }, + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body) as ErrorResponse; + expect(body.error).toMatch(/private/i); + await server.close(); + }); + + it('should reject invalid URL', async () => { + const server = await createTestServer(); + const token = await getAuthToken(server); + const response = await server.inject({ + method: 'POST', + url: '/api/models/discover', + payload: { baseUrl: 'not-a-url' }, + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body) as ErrorResponse; + expect(body.error).toMatch(/private/i); + await server.close(); + }); +}); 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..58643fc 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,152 @@ function toDockerHostUrl(url: string): string { return url.replace(/\blocalhost\b|127\.0\.0\.1/g, 'host.docker.internal'); } +/** Check if a URL targets a local discovery host (localhost, 127.0.0.1, host.docker.internal). */ +function isLocalDiscoveryUrl(urlStr: string): boolean { + try { + const hostname = new URL(urlStr).hostname.toLowerCase(); + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === 'host.docker.internal'; + } catch { + return false; + } +} + +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', 'slack', 'signal', 'whatsapp', 'matrix', 'nostr', + 'twitter', 'facebook', 'instagram', 'teams', 'line', 'wechat', 'viber', + 'kik', 'twitch', 'reddit', 'mastodon', 'bluesky', 'rocketchat', + 'mattermost', 'zulip', 'irc', 'xmpp', 'sms', 'email', 'googlechat', + 'webex', 'web', 'webhook', +]); + +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; + if (lowerIp === '::') return true; + if (lowerIp.startsWith('2001:db8:')) return true; + if (lowerIp.startsWith('2001:0:')) return true; + if (lowerIp.startsWith('100:')) return true; + if (lowerIp.startsWith('64:ff9b:')) 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 > 255 || b > 255 || c > 255 || d > 255) return true; + 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'); + } + } + + // All resolved addresses validated as non-private. + // Preserve original hostname so HTTPS TLS/SNI and cert validation work. + return { resolvedUrl: urlStr, originalHost: hostname }; +} + async function resolveHostPaths(config: ReturnType): Promise<{ hostDataDir: string; hostSecretsDir: string; @@ -146,7 +293,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 +423,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 +449,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 +558,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 +783,47 @@ 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' }; } + const fetchBase = toDockerHostUrl(baseUrl); + const isLocal = isLocalDiscoveryUrl(baseUrl) || isLocalDiscoveryUrl(fetchBase); + + if (!isLocal && (isPrivateUrl(baseUrl) || 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); } });