Root cause
Authentication & access control introduced
feat: add embedded HTTP server for zero-config CLI access
How AI contributed: Direct introductionPublished Jun 1, 2026 in
enderfga/claw-orchestrator
CNA CVE-2026-10281 / GitHub-reviewed GHSA-q6qc-xp4q-rjq5 name Enderfga claw-orchestrator EmbeddedServer missing authentication, patch d0b02a80, last affected 3.5.5, fixed 3.5.6. Parent 0a283f45 has no src/embedded-server.ts. Direct commit f82c7836 (Claude Opus 4.6) adds the server listening on 127.0.0.1 with no authToken. Fix d0b02a80 (closes #61) auto-generates a token and requires Bearer/cookie/query on every non-/health route. v3.5.5 contains f82c7836 without d0b02a80; v3.5.6 contains the fix. edd97774 only adds --host 0.0.0.0 bind after the unauthenticated server already existed; it is a negative-control bad edge, not origin.
A Claude-coauthored commit added src/embedded-server.ts, an embedded control server that listened on 127.0.0.1 and answered every non-health command route without authentication while setting a wildcard CORS origin, so any web page in a browser could drive the control surface through cross-origin requests. The fix auto-generates a 32-byte bearer token into a mode-0600 file and requires it on all non-health requests.
Root cause
feat: add embedded HTTP server for zero-config CLI access
Fix
The fix makes authentication the default by generating and storing an owner-only token when none is configured, rejects every non-health request without a matching bearer, cookie, or bootstrap query token, and makes the CLI send that token automatically.
Case facts
@@ -0,0 +1,229 @@+/**+ * Embedded HTTP Server — auto-starts with plugin, serves CLI commands+ *+ * This is NOT a separate process. It runs inside the plugin (or standalone)+ * and provides HTTP endpoints for the CLI to connect to.+ *+ * Users never need to configure or manage this — it just works.+ */++import * as http from 'node:http';+import { SessionManager } from './session-manager.js';+import type { EffortLevel } from './types.js';++const DEFAULT_PORT = 18796;++export class EmbeddedServer {+ private server: http.Server | null = null;+ private manager: SessionManager;+ private port: number;++ constructor(manager: SessionManager, port?: number) {+ this.manager = manager;+ this.port = port || DEFAULT_PORT;+ }++ async start(): Promise<number> {+ return new Promise((resolve, reject) => {+ this.server = http.createServer((req, res) => this.handleRequest(req, res));++ this.server.on('error', (err: NodeJS.ErrnoException) => {+ if (err.code === 'EADDRINUSE') {+ // Port already in use — another instance running, skip+ console.log(`[embedded-server] Port ${this.port} in use, skipping (another instance running)`);+ this.server = null;+ resolve(0);+ } else {+ reject(err);+ }+ });++ this.server.listen(this.port, '127.0.0.1', () => {+ console.log(`[embedded-server] Listening on http://127.0.0.1:${this.port}`);+ resolve(this.port);+ });+ });+ }++ async stop(): Promise<void> {+ if (!this.server) return;+ return new Promise(resolve => {+ this.server!.close(() => resolve());+ });+ }++ private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {+ // CORS+ res.setHeader('Access-Control-Allow-Origin', '*');+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');+ if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }++ const url = new URL(req.url || '/', `http://localhost:${this.port}`);+ const path = url.pathname;++ // Read body for POST+ if (req.method === 'POST') {+ let body = '';+ req.on('data', chunk => { body += chunk; });+ req.on('end', () => {+ let parsed: Record<string, unknown> = {};+ try { parsed = JSON.parse(body || '{}'); } catch {}+ this.route(path, parsed, url.searchParams, res);+ });+ } else {+ this.route(path, {}, url.searchParams, res);+ }+ }++ private async route(+ path: string, body: Record<string, unknown>,+ query: URLSearchParams, res: http.ServerResponse,+ ): Promise<void> {+ try {+ const json = (status: number, data: unknown) => {+ res.writeHead(status, { 'Content-Type': 'application/json' });+ res.end(JSON.stringify(data));+ };++ // ─── Session Routes ──────────────────────────────────────────@@ -11,6 +11,7 @@ import * as http from 'node:http'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os';+import * as crypto from 'node:crypto'; import { SessionManager } from './session-manager.js'; import { sanitizeCwd, validateRegex } from './validation.js'; import type { EffortLevel } from './types.js';@@ -51,23 +52,45 @@ export class EmbeddedServer { return recent.length <= this._rateLimit; } + private _writeTokenFile(token: string): void {+ const tokenDir = path.join(os.homedir(), '.openclaw');+ try {+ if (!fs.existsSync(tokenDir)) fs.mkdirSync(tokenDir, { recursive: true });+ fs.writeFileSync(path.join(tokenDir, 'server-token'), token, { mode: 0o600 });+ } catch (err) {+ console.warn(`[embedded-server] failed to write server-token file: ${(err as Error).message}`);+ }+ }+ async start(): Promise<number> {- // Auth token: opt-in via OPENCLAW_SERVER_TOKEN env var.- // When set, all requests (except /health) must include Authorization: Bearer <token>.- // Default: no auth (localhost-only is the primary security boundary).+ // Auth token policy (changed in 3.5.6 — closes CWE-306 from issue #61):+ //+ // default → auto-generate 32-byte random token,+ // write to ~/.openclaw/server-token mode 0600.+ // Required on every non-/health request via+ // Bearer header OR `clawo_auth` cookie OR+ // ?token=<v> query.+ // OPENCLAW_SERVER_TOKEN=<v> → use the explicit token (legacy behaviour).+ // OPENCLAW_SERVER_TOKEN=disabled → opt out of auth entirely. Only safe on+ // a single-user host; loud warning at start.+ //+ // The file is mode 0600 (owner-read-only). Same-user CLI + dashboard read it;+ // other users on the same box cannot. Browsers reach /dashboard via the+ // `?token=<v>` query once; the server replies with a Set-Cookie so subsequent+ // requests authenticate via the cookie. const envToken = process.env.OPENCLAW_SERVER_TOKEN;- if (envToken) {+ if (envToken === 'disabled') {+ this.authToken = null;+ console.warn(+ '[embedded-server] OPENCLAW_SERVER_TOKEN=disabled — authentication is OFF. ' ++ 'All endpoints are reachable to any process that can connect. Only safe on a trusted single-user host.',+ );+ } else if (envToken) { this.authToken = envToken;- // Write token to file for CLI to read- const tokenDir = path.join(os.homedir(), '.openclaw');- try {- if (!fs.existsSync(tokenDir)) fs.mkdirSync(tokenDir, { recursive: true });- fs.writeFileSync(path.join(tokenDir, 'server-token'), this.authToken, { mode: 0o600 });- } catch {- /* best effort */- }+ this._writeTokenFile(this.authToken); } else {- this.authToken = null;+ this.authToken = crypto.randomBytes(32).toString('hex');+ this._writeTokenFile(this.authToken); } this._rateLimitCleanupTimer = setInterval(() => {@@ -95,9 +118,15 @@ export class EmbeddedServer { }); this.server.listen(this.port, this.host, () => {- console.log(- `[embedded-server] Listening on http://${this.host}:${this.port}${this.authToken ? ' (auth enabled)' : ''}`,- );+ if (this.authToken) {+ console.log(`[embedded-server] Listening on http://${this.host}:${this.port} (auth enabled)`);+ console.log(`[embedded-server] Token file: ${path.join(os.homedir(), '.openclaw', 'server-token')}`);+ console.log(+ `[embedded-server] Dashboard: http://${this.host}:${this.port}/dashboard?token=${this.authToken}`,+ );+ } else {+ console.log(`[embedded-server] Listening on http://${this.host}:${this.port} (AUTH DISABLED)`);+ } resolve(this.port); }); });@@ -143,14 +172,36 @@ export class EmbeddedServer { const url = new URL(req.url || '/', `http://localhost:${this.port}`); const path = url.pathname; @@ -10,11 +10,35 @@ import { Command } from 'commander'; import { createRequire } from 'node:module';+import * as fs from 'node:fs';+import * as path from 'node:path';+import * as os from 'node:os'; function getBaseUrl(): string { return process.env.CLAWO_API_URL || process.env.CLAUDE_CODE_API_URL || 'http://127.0.0.1:18796'; } +/**+ * Locate the auth token the embedded server requires (3.5.6+):+ * 1. CLAWO_AUTH_TOKEN env (explicit override)+ * 2. OPENCLAW_SERVER_TOKEN env (same env the server reads — handy when both+ * processes share the same shell)+ * 3. ~/.openclaw/server-token file (the server writes this at startup)+ * Returns null if nothing is found — caller falls through to an unauthenticated+ * request, which the server will reject with 401 unless `OPENCLAW_SERVER_TOKEN=disabled`.+ */+function getAuthToken(): string | null {+ const envToken = process.env.CLAWO_AUTH_TOKEN || process.env.OPENCLAW_SERVER_TOKEN;+ if (envToken && envToken !== 'disabled') return envToken;+ try {+ const filePath = path.join(os.homedir(), '.openclaw', 'server-token');+ const t = fs.readFileSync(filePath, 'utf-8').trim();+ return t || null;+ } catch {+ return null;+ }+}+ function getCliVersion(): string { try { const _require = createRequire(import.meta.url);@@ -29,10 +53,10 @@ function getCliVersion(): string { // ─── HTTP Client ───────────────────────────────────────────────────────────── async function api(path: string, method = 'GET', body?: unknown): Promise<Record<string, unknown>> {- const opts: RequestInit = {- method,- headers: { 'Content-Type': 'application/json' },- };+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };+ const token = getAuthToken();+ if (token) headers.Authorization = `Bearer ${token}`;+ const opts: RequestInit = { method, headers }; if (body) opts.body = JSON.stringify(body); try { const base = getBaseUrl();AI-assisted change b0a355b16b89bad21fb99a13f2c96fe3ad830c9e6cceb055f1d6607b3deae198 · Fix dec5aaaf3895547f773c879e5f58931b50d68d094a00bda3f66e3a02dc2d98ec