From 6afcf94ca20097b0c71661ac38eddbe01181459b Mon Sep 17 00:00:00 2001 From: Saad Khan Date: Sat, 11 Apr 2026 14:11:36 -0400 Subject: [PATCH] security: bind dashboard to loopback, reject cross-origin WS upgrades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard server previously called app.listen(port) with no host argument, binding to 0.0.0.0. Combined with the fact that none of the HTTP or WebSocket endpoints require authentication, this meant the dashboard was reachable from the LAN and cross-origin from any webpage the user visited in a browser. Exposed surface included: - GET /api/files — contents of cerebrum.md, memory.md, buglog.json, token-ledger.json, and suggestions.json. - POST /api/cron/run/:taskId and the WebSocket "trigger_task" handler — both execute cron tasks, including ai_task actions that shell out to claude -p in the project root. This change: 1. Binds the HTTP/WebSocket server to 127.0.0.1 by default. The bind address is read from openwolf.dashboard.bind in .wolf/config.json (new optional field), defaulting to "127.0.0.1" when the field is absent so existing installs become loopback-only on restart. 2. Adds a verifyClient check on the WebSocket upgrade that allows same-origin connections (dashboard loaded from http://127.0.0.1: or http://localhost:) and non-browser clients (no Origin header), while rejecting any other Origin. 3. Logs a warning when the dashboard is bound to a non-loopback address, to make the security implication explicit for anyone who sets bind: "0.0.0.0" on purpose. 4. Documents the new default and the daemon.dashboard.bind opt-in in the README. Users who were intentionally exposing the dashboard to their network will need to set "bind": "0.0.0.0" under openwolf.dashboard in their .wolf/config.json after upgrading. --- README.md | 4 ++++ src/daemon/wolf-daemon.ts | 45 ++++++++++++++++++++++++++++++++++----- src/templates/config.json | 3 ++- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c19756e..5c9b971 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,10 @@ OpenWolf is not an AI wrapper. It is 6 hook scripts and a `.wolf/` directory. It - Optional: PM2 for persistent background tasks - Optional: `puppeteer-core` for Design QC screenshots +## Dashboard network exposure + +The dashboard server binds to `127.0.0.1` by default. Its HTTP and WebSocket endpoints are not authenticated, so loopback-only is the safe default — they hand out the contents of `.wolf/` and can trigger cron tasks (including `ai_task` actions that shell out to `claude -p`). If you actually need to reach the dashboard from another machine, set `openwolf.dashboard.bind` in `.wolf/config.json` to `"0.0.0.0"` (or a specific interface) and put it behind your own authenticated reverse proxy. + ## Limitations - Claude Code hooks are a relatively new feature. OpenWolf falls back to `CLAUDE.md` instructions when hooks don't fire. diff --git a/src/daemon/wolf-daemon.ts b/src/daemon/wolf-daemon.ts index 6a3c93f..1afe47e 100644 --- a/src/daemon/wolf-daemon.ts +++ b/src/daemon/wolf-daemon.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import type { IncomingMessage } from "node:http"; import express from "express"; import { WebSocketServer, WebSocket } from "ws"; import { findProjectRoot } from "../scanner/project-root.js"; @@ -19,7 +20,7 @@ const wolfDir = path.join(projectRoot, ".wolf"); interface WolfConfig { openwolf: { daemon: { port: number; log_level: string }; - dashboard: { enabled: boolean; port: number }; + dashboard: { enabled: boolean; port: number; bind?: string }; cron: { enabled: boolean; heartbeat_interval_minutes: number }; }; } @@ -27,11 +28,16 @@ interface WolfConfig { const config = readJSON(path.join(wolfDir, "config.json"), { openwolf: { daemon: { port: 18790, log_level: "info" }, - dashboard: { enabled: true, port: 18791 }, + dashboard: { enabled: true, port: 18791, bind: "127.0.0.1" }, cron: { enabled: true, heartbeat_interval_minutes: 30 }, }, }); +// Dashboard bind address. Defaults to loopback so the unauthenticated API +// and WebSocket endpoints are not exposed to the LAN. Set to "0.0.0.0" in +// .wolf/config.json only if you explicitly need network access. +const bind = config.openwolf.dashboard.bind ?? "127.0.0.1"; + const logger = new Logger( path.join(wolfDir, "daemon.log"), config.openwolf.daemon.log_level as "debug" | "info" | "warn" | "error" @@ -187,12 +193,41 @@ app.get("/{*path}", (_req, res) => { // Start HTTP server const port = config.openwolf.dashboard.port; -const server = app.listen(port, () => { - logger.info(`Dashboard server listening on port ${port}`); +const server = app.listen(port, bind, () => { + logger.info(`Dashboard server listening on ${bind}:${port}`); + if (bind !== "127.0.0.1" && bind !== "localhost" && bind !== "::1") { + logger.warn( + `Dashboard bound to ${bind} — HTTP and WebSocket endpoints are reachable from the network. ` + + `None of these endpoints require authentication.` + ); + } }); +// Allow same-origin WebSocket connections (dashboard loaded from +// http://:) and non-browser clients (no Origin header). Reject +// any other Origin to prevent a visited webpage from driving the daemon. +function isAllowedOrigin(origin: string | undefined): boolean { + if (!origin) return true; // non-browser clients don't send Origin + const allowed = new Set([ + `http://127.0.0.1:${port}`, + `http://localhost:${port}`, + `http://[::1]:${port}`, + ]); + if (bind !== "127.0.0.1" && bind !== "localhost" && bind !== "::1") { + allowed.add(`http://${bind}:${port}`); + } + return allowed.has(origin); +} + // WebSocket server -const wss = new WebSocketServer({ server }); +const wss = new WebSocketServer({ + server, + verifyClient: (info: { origin: string; req: IncomingMessage; secure: boolean }) => { + if (isAllowedOrigin(info.origin || undefined)) return true; + logger.warn(`Rejected WebSocket upgrade: origin=${info.origin}`); + return false; + }, +}); wss.on("connection", (ws) => { wsClients.add(ws); diff --git a/src/templates/config.json b/src/templates/config.json index d2c76ae..4a6a5c9 100644 --- a/src/templates/config.json +++ b/src/templates/config.json @@ -58,7 +58,8 @@ }, "dashboard": { "enabled": true, - "port": 18791 + "port": 18791, + "bind": "127.0.0.1" }, "designqc": { "enabled": true,