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 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> {
|
export async function parseJsonBody<T>(req: IncomingMessage): Promise<T> {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -12,27 +24,49 @@ export async function parseJsonBody<T>(req: IncomingMessage): Promise<T> {
|
|||||||
return JSON.parse(raw) as 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-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 {
|
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void {
|
||||||
setCorsHeaders(res);
|
|
||||||
res.statusCode = statusCode;
|
res.statusCode = statusCode;
|
||||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
res.end(JSON.stringify(payload));
|
res.end(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendNoContent(res: ServerResponse): void {
|
export function sendNoContent(res: ServerResponse): void {
|
||||||
setCorsHeaders(res);
|
|
||||||
res.statusCode = 204;
|
res.statusCode = 204;
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendText(res: ServerResponse, statusCode: number, text: string): void {
|
export function sendText(res: ServerResponse, statusCode: number, text: string): void {
|
||||||
setCorsHeaders(res);
|
|
||||||
res.statusCode = statusCode;
|
res.statusCode = statusCode;
|
||||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||||
res.end(text);
|
res.end(text);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from 'http';
|
import type { IncomingMessage, ServerResponse } from 'http';
|
||||||
import type { HostApiContext } from '../context';
|
import type { HostApiContext } from '../context';
|
||||||
import { parseJsonBody } from '../route-utils';
|
import { parseJsonBody, sendJson } from '../route-utils';
|
||||||
import { setCorsHeaders, sendJson, sendNoContent } from '../route-utils';
|
|
||||||
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../../utils/openclaw-doctor';
|
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../../utils/openclaw-doctor';
|
||||||
|
|
||||||
export async function handleAppRoutes(
|
export async function handleAppRoutes(
|
||||||
@@ -11,7 +10,7 @@ export async function handleAppRoutes(
|
|||||||
ctx: HostApiContext,
|
ctx: HostApiContext,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (url.pathname === '/api/events' && req.method === 'GET') {
|
if (url.pathname === '/api/events' && req.method === 'GET') {
|
||||||
setCorsHeaders(res);
|
// CORS headers are already set by the server middleware.
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
'Cache-Control': 'no-cache, no-transform',
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
@@ -32,10 +31,7 @@ export async function handleAppRoutes(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
// OPTIONS is handled by the server middleware; no route-level handler needed.
|
||||||
sendNoContent(res);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||||
import { PORTS } from '../utils/config';
|
import { PORTS } from '../utils/config';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
@@ -14,7 +15,7 @@ import { handleSkillRoutes } from './routes/skills';
|
|||||||
import { handleFileRoutes } from './routes/files';
|
import { handleFileRoutes } from './routes/files';
|
||||||
import { handleSessionRoutes } from './routes/sessions';
|
import { handleSessionRoutes } from './routes/sessions';
|
||||||
import { handleCronRoutes } from './routes/cron';
|
import { handleCronRoutes } from './routes/cron';
|
||||||
import { sendJson } from './route-utils';
|
import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils';
|
||||||
|
|
||||||
type RouteHandler = (
|
type RouteHandler = (
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
@@ -38,10 +39,61 @@ const routeHandlers: RouteHandler[] = [
|
|||||||
handleUsageRoutes,
|
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 {
|
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) => {
|
const server = createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
|
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) {
|
for (const handler of routeHandlers) {
|
||||||
if (await handler(req, res, requestUrl, ctx)) {
|
if (await handler(req, res, requestUrl, ctx)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -170,9 +170,19 @@ function createWindow(): BrowserWindow {
|
|||||||
show: false,
|
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 }) => {
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
|
||||||
shell.openExternal(url);
|
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' };
|
return { action: 'deny' };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||||
import { PORTS } from '../../utils/config';
|
import { PORTS } from '../../utils/config';
|
||||||
|
import { getHostApiToken } from '../../api/server';
|
||||||
|
|
||||||
type HostApiFetchRequest = {
|
type HostApiFetchRequest = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -10,6 +11,10 @@ type HostApiFetchRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function registerHostApiProxyHandlers(): void {
|
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) => {
|
ipcMain.handle('hostapi:fetch', async (_, request: HostApiFetchRequest) => {
|
||||||
try {
|
try {
|
||||||
const path = typeof request?.path === 'string' ? request.path : '';
|
const path = typeof request?.path === 'string' ? request.path : '';
|
||||||
@@ -19,6 +24,8 @@ export function registerHostApiProxyHandlers(): void {
|
|||||||
|
|
||||||
const method = (request.method || 'GET').toUpperCase();
|
const method = (request.method || 'GET').toUpperCase();
|
||||||
const headers: Record<string, string> = { ...(request.headers || {}) };
|
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;
|
let body: string | undefined;
|
||||||
|
|
||||||
if (request.body !== undefined && request.body !== null) {
|
if (request.body !== undefined && request.body !== null) {
|
||||||
@@ -26,11 +33,13 @@ export function registerHostApiProxyHandlers(): void {
|
|||||||
body = request.body;
|
body = request.body;
|
||||||
} else {
|
} else {
|
||||||
body = JSON.stringify(request.body);
|
body = JSON.stringify(request.body);
|
||||||
|
}
|
||||||
|
// 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']) {
|
if (!headers['Content-Type'] && !headers['content-type']) {
|
||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const response = await proxyAwareFetch(`http://127.0.0.1:${PORTS.CLAWX_HOST_API}${path}`, {
|
const response = await proxyAwareFetch(`http://127.0.0.1:${PORTS.CLAWX_HOST_API}${path}`, {
|
||||||
method,
|
method,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const electronAPI = {
|
|||||||
'gateway:rpc',
|
'gateway:rpc',
|
||||||
'gateway:httpProxy',
|
'gateway:httpProxy',
|
||||||
'hostapi:fetch',
|
'hostapi:fetch',
|
||||||
|
'hostapi:token',
|
||||||
'gateway:health',
|
'gateway:health',
|
||||||
'gateway:getControlUiUrl',
|
'gateway:getControlUiUrl',
|
||||||
// OpenClaw
|
// OpenClaw
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ import { normalizeAppError } from './error-model';
|
|||||||
const HOST_API_PORT = 3210;
|
const HOST_API_PORT = 3210;
|
||||||
const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`;
|
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 = {
|
type HostApiProxyResponse = {
|
||||||
ok?: boolean;
|
ok?: boolean;
|
||||||
data?: {
|
data?: {
|
||||||
@@ -182,10 +195,12 @@ export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Browser-only fallback (non-Electron environments).
|
// Browser-only fallback (non-Electron environments).
|
||||||
|
const token = await getHostApiToken();
|
||||||
const response = await fetch(`${HOST_API_BASE}${path}`, {
|
const response = await fetch(`${HOST_API_BASE}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
...(init?.headers || {}),
|
...(init?.headers || {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -204,7 +219,11 @@ export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createHostEventSource(path = '/api/events'): EventSource {
|
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 {
|
export function getHostApiBase(): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user