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:
committed by
GitHub
Unverified
parent
83858fdf73
commit
b786b773f1
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' };
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ const electronAPI = {
|
||||
'gateway:rpc',
|
||||
'gateway:httpProxy',
|
||||
'hostapi:fetch',
|
||||
'hostapi:token',
|
||||
'gateway:health',
|
||||
'gateway:getControlUiUrl',
|
||||
// OpenClaw
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user