fix(security): mitigate GHSA-9gf9-7xcc-xcq9 & GHSA-vf6c-fgmq-xm78 + bug fixes (#667)

Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lingxuan Zuo
2026-03-25 22:02:28 +08:00
committed by GitHub
Unverified
parent 83858fdf73
commit b786b773f1
7 changed files with 141 additions and 20 deletions

View File

@@ -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<T>(req: IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
@@ -12,27 +24,49 @@ export async function parseJsonBody<T>(req: IncomingMessage): Promise<T> {
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);

View File

@@ -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<boolean> {
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;
}

View File

@@ -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;

View File

@@ -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' };
});

View File

@@ -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<string, string> = { ...(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';
}
}

View File

@@ -23,6 +23,7 @@ const electronAPI = {
'gateway:rpc',
'gateway:httpProxy',
'hostapi:fetch',
'hostapi:token',
'gateway:health',
'gateway:getControlUiUrl',
// OpenClaw

View File

@@ -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<string> {
if (cachedHostApiToken) return cachedHostApiToken;
try {
cachedHostApiToken = await invokeIpc<string>('hostapi:token');
} catch {
cachedHostApiToken = '';
}
return cachedHostApiToken ?? '';
}
type HostApiProxyResponse = {
ok?: boolean;
data?: {
@@ -182,10 +195,12 @@ export async function hostApiFetch<T>(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<T>(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 {