diff --git a/electron/api/route-utils.ts b/electron/api/route-utils.ts index a0ea42b7f..f48fe0350 100644 --- a/electron/api/route-utils.ts +++ b/electron/api/route-utils.ts @@ -1,4 +1,16 @@ import type { IncomingMessage, ServerResponse } from 'http'; +import { PORTS } from '../utils/config'; + +/** + * Allowed CORS origins — only the Electron renderer (Vite dev or production) + * and the OpenClaw Gateway are permitted to make cross-origin requests. + */ +const ALLOWED_ORIGINS = new Set([ + `http://127.0.0.1:${PORTS.CLAWX_DEV}`, + `http://localhost:${PORTS.CLAWX_DEV}`, + `http://127.0.0.1:${PORTS.OPENCLAW_GATEWAY}`, + `http://localhost:${PORTS.OPENCLAW_GATEWAY}`, +]); export async function parseJsonBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; @@ -12,27 +24,49 @@ export async function parseJsonBody(req: IncomingMessage): Promise { return JSON.parse(raw) as T; } -export function setCorsHeaders(res: ServerResponse): void { - res.setHeader('Access-Control-Allow-Origin', '*'); +/** + * Validate that mutation requests (POST/PUT/DELETE) carry a JSON Content-Type. + * This prevents "simple request" CSRF where the browser skips the preflight + * when Content-Type is text/plain or application/x-www-form-urlencoded. + */ +export function requireJsonContentType(req: IncomingMessage): boolean { + if (req.method === 'GET' || req.method === 'OPTIONS' || req.method === 'HEAD') { + return true; + } + // Requests without a body (content-length 0 or absent) are safe — CSRF + // "simple request" attacks rely on sending a crafted body. + const contentLength = req.headers['content-length']; + if (contentLength === '0' || contentLength === undefined) { + return true; + } + const ct = req.headers['content-type'] || ''; + return ct.includes('application/json'); +} + +export function setCorsHeaders(res: ServerResponse, origin?: string): void { + // Only reflect the Origin header back if it is in the allow-list. + // Omitting the header for unknown origins causes the browser to block + // the response — this is the intended behavior for untrusted callers. + if (origin && ALLOWED_ORIGINS.has(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); } export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void { - setCorsHeaders(res); res.statusCode = statusCode; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(payload)); } export function sendNoContent(res: ServerResponse): void { - setCorsHeaders(res); res.statusCode = 204; res.end(); } export function sendText(res: ServerResponse, statusCode: number, text: string): void { - setCorsHeaders(res); res.statusCode = statusCode; res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.end(text); diff --git a/electron/api/routes/app.ts b/electron/api/routes/app.ts index 467dc5223..c1573c007 100644 --- a/electron/api/routes/app.ts +++ b/electron/api/routes/app.ts @@ -1,7 +1,6 @@ import type { IncomingMessage, ServerResponse } from 'http'; import type { HostApiContext } from '../context'; -import { parseJsonBody } from '../route-utils'; -import { setCorsHeaders, sendJson, sendNoContent } from '../route-utils'; +import { parseJsonBody, sendJson } from '../route-utils'; import { runOpenClawDoctor, runOpenClawDoctorFix } from '../../utils/openclaw-doctor'; export async function handleAppRoutes( @@ -11,7 +10,7 @@ export async function handleAppRoutes( ctx: HostApiContext, ): Promise { if (url.pathname === '/api/events' && req.method === 'GET') { - setCorsHeaders(res); + // CORS headers are already set by the server middleware. res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', @@ -32,10 +31,7 @@ export async function handleAppRoutes( return true; } - if (req.method === 'OPTIONS') { - sendNoContent(res); - return true; - } + // OPTIONS is handled by the server middleware; no route-level handler needed. return false; } diff --git a/electron/api/server.ts b/electron/api/server.ts index 64523d086..73838d4d7 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import { PORTS } from '../utils/config'; import { logger } from '../utils/logger'; @@ -14,7 +15,7 @@ import { handleSkillRoutes } from './routes/skills'; import { handleFileRoutes } from './routes/files'; import { handleSessionRoutes } from './routes/sessions'; import { handleCronRoutes } from './routes/cron'; -import { sendJson } from './route-utils'; +import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils'; type RouteHandler = ( req: IncomingMessage, @@ -38,10 +39,61 @@ const routeHandlers: RouteHandler[] = [ handleUsageRoutes, ]; +/** + * Per-session secret token used to authenticate Host API requests. + * Generated once at server start and shared with the renderer via IPC. + * This prevents cross-origin attackers from reading sensitive data even + * if they can reach 127.0.0.1:3210 (the CORS wildcard alone is not + * sufficient because browsers attach the Origin header but not a secret). + */ +let hostApiToken: string = ''; + +/** Retrieve the current Host API auth token (for use by IPC proxy). */ +export function getHostApiToken(): string { + return hostApiToken; +} + export function startHostApiServer(ctx: HostApiContext, port = PORTS.CLAWX_HOST_API): Server { + // Generate a cryptographically random token for this session. + hostApiToken = randomBytes(32).toString('hex'); + const server = createServer(async (req, res) => { try { const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`); + // ── CORS headers ───────────────────────────────────────── + // Set origin-aware CORS headers early so every response + // (including error responses) carries them consistently. + const origin = req.headers.origin; + setCorsHeaders(res, origin); + + // CORS preflight — respond before auth so browsers can negotiate. + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + return; + } + + // ── Auth gate ────────────────────────────────────────────── + // Every non-preflight request must carry a valid Bearer token. + // Accept via Authorization header (preferred) or ?token= query + // parameter (for EventSource which cannot set custom headers). + const authHeader = req.headers.authorization || ''; + const bearerToken = authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : (requestUrl.searchParams.get('token') || ''); + if (bearerToken !== hostApiToken) { + sendJson(res, 401, { success: false, error: 'Unauthorized' }); + return; + } + + // ── Content-Type gate (anti-CSRF) ────────────────────────── + // Mutation requests must use application/json to force a CORS + // preflight, preventing "simple request" CSRF attacks. + if (!requireJsonContentType(req)) { + sendJson(res, 415, { success: false, error: 'Content-Type must be application/json' }); + return; + } + for (const handler of routeHandlers) { if (await handler(req, res, requestUrl, ctx)) { return; diff --git a/electron/main/index.ts b/electron/main/index.ts index 1fccc07c3..d1ffb35dc 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -170,9 +170,19 @@ function createWindow(): BrowserWindow { show: false, }); - // Handle external links + // Handle external links — only allow safe protocols to prevent arbitrary + // command execution via shell.openExternal() (e.g. file://, ms-msdt:, etc.) win.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url); + try { + const parsed = new URL(url); + if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { + shell.openExternal(url); + } else { + logger.warn(`Blocked openExternal for disallowed protocol: ${parsed.protocol}`); + } + } catch { + logger.warn(`Blocked openExternal for malformed URL: ${url}`); + } return { action: 'deny' }; }); diff --git a/electron/main/ipc/host-api-proxy.ts b/electron/main/ipc/host-api-proxy.ts index 40b71ff7c..a82f56520 100644 --- a/electron/main/ipc/host-api-proxy.ts +++ b/electron/main/ipc/host-api-proxy.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; import { PORTS } from '../../utils/config'; +import { getHostApiToken } from '../../api/server'; type HostApiFetchRequest = { path: string; @@ -10,6 +11,10 @@ type HostApiFetchRequest = { }; export function registerHostApiProxyHandlers(): void { + // Expose the per-session auth token to the renderer so the browser-fallback + // path in host-api.ts can authenticate against the Host API server. + ipcMain.handle('hostapi:token', () => getHostApiToken()); + ipcMain.handle('hostapi:fetch', async (_, request: HostApiFetchRequest) => { try { const path = typeof request?.path === 'string' ? request.path : ''; @@ -19,6 +24,8 @@ export function registerHostApiProxyHandlers(): void { const method = (request.method || 'GET').toUpperCase(); const headers: Record = { ...(request.headers || {}) }; + // Inject the per-session auth token so the Host API server accepts this request. + headers['Authorization'] = `Bearer ${getHostApiToken()}`; let body: string | undefined; if (request.body !== undefined && request.body !== null) { @@ -26,9 +33,11 @@ export function registerHostApiProxyHandlers(): void { body = request.body; } else { body = JSON.stringify(request.body); - if (!headers['Content-Type'] && !headers['content-type']) { - headers['Content-Type'] = 'application/json'; - } + } + // Ensure Content-Type is set for requests with a body so the + // server's anti-CSRF Content-Type gate does not reject them. + if (!headers['Content-Type'] && !headers['content-type']) { + headers['Content-Type'] = 'application/json'; } } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index b3f4afbfb..69e33f685 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -23,6 +23,7 @@ const electronAPI = { 'gateway:rpc', 'gateway:httpProxy', 'hostapi:fetch', + 'hostapi:token', 'gateway:health', 'gateway:getControlUiUrl', // OpenClaw diff --git a/src/lib/host-api.ts b/src/lib/host-api.ts index b5a81bc48..2f3e5e689 100644 --- a/src/lib/host-api.ts +++ b/src/lib/host-api.ts @@ -5,6 +5,19 @@ import { normalizeAppError } from './error-model'; const HOST_API_PORT = 3210; const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`; +/** Cached Host API auth token, fetched once from the main process via IPC. */ +let cachedHostApiToken: string | null = null; + +async function getHostApiToken(): Promise { + if (cachedHostApiToken) return cachedHostApiToken; + try { + cachedHostApiToken = await invokeIpc('hostapi:token'); + } catch { + cachedHostApiToken = ''; + } + return cachedHostApiToken ?? ''; +} + type HostApiProxyResponse = { ok?: boolean; data?: { @@ -182,10 +195,12 @@ export async function hostApiFetch(path: string, init?: RequestInit): Promise } // Browser-only fallback (non-Electron environments). + const token = await getHostApiToken(); const response = await fetch(`${HOST_API_BASE}${path}`, { ...init, headers: { 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, ...(init?.headers || {}), }, }); @@ -204,7 +219,11 @@ export async function hostApiFetch(path: string, init?: RequestInit): Promise } export function createHostEventSource(path = '/api/events'): EventSource { - return new EventSource(`${HOST_API_BASE}${path}`); + // EventSource does not support custom headers, so pass the auth token + // as a query parameter. The server accepts both mechanisms. + const separator = path.includes('?') ? '&' : '?'; + const tokenParam = `token=${encodeURIComponent(cachedHostApiToken ?? '')}`; + return new EventSource(`${HOST_API_BASE}${path}${separator}${tokenParam}`); } export function getHostApiBase(): string {