From d7ba6fa6e2a4c26e3b99c09cd06808b8a5b4ed41 Mon Sep 17 00:00:00 2001 From: Morteza PRK Date: Sun, 25 Oct 2026 01:01:32 +0200 Subject: [PATCH 1/2] fix: restrict dashboard to local access --- src/dashboard.ts | 21 +++++++++++++++++++-- test/dashboard.test.ts | 21 +++++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/dashboard.ts b/src/dashboard.ts index 9cf507c..774299f 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -216,7 +216,6 @@ export interface DashboardOptions { function sendJson(res: ServerResponse, data: unknown): void { res.writeHead(200, { "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", }) res.end(JSON.stringify(data)) } @@ -335,6 +334,11 @@ function handleDashboardRequest( res: ServerResponse, options?: DashboardOptions, ): void { + if (!isAllowedHost(req.headers.host, port)) { + res.writeHead(421, { "Content-Type": "text/plain" }) + res.end("Misdirected Request") + return + } const url = new URL(req.url ?? "/", `http://${req.headers.host ?? `localhost:${port}`}`) if (url.pathname === "/api/health") { @@ -369,6 +373,19 @@ function handleDashboardRequest( res.end("Not Found") } +/** Accept only localhost and loopback Host values for this listener. */ +function isAllowedHost(host: string | undefined, port: number): boolean { + if (!host) return false + const normalized = host.toLowerCase() + const validHosts = new Set(["localhost", "127.0.0.1", "[::1]"]) + try { + const parsed = new URL(`http://${normalized}`) + return validHosts.has(parsed.hostname) && parsed.port === String(port) && parsed.username === "" && parsed.password === "" + } catch { + return false + } +} + function toDashboardServer(server: Server): DashboardServer { return { stop(force?: boolean) { @@ -424,7 +441,7 @@ export async function startDashboard(db: Database, port: number, options?: Dashb resolve(null) }) - server.listen(port, () => { + server.listen(port, "127.0.0.1", () => { log(`dashboard:started port=${port} url=http://localhost:${port}`) resolve(toDashboardServer(server)) }) diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts index 955c358..fb0dfa0 100644 --- a/test/dashboard.test.ts +++ b/test/dashboard.test.ts @@ -49,11 +49,28 @@ describe("dashboard", () => { const res = await fetch(`http://localhost:${port}/api/health`) expect(res.status).toBe(200) expect(res.headers.get("content-type")).toContain("application/json") - expect(res.headers.get("access-control-allow-origin")).toBe("*") + expect(res.headers.get("access-control-allow-origin")).toBeNull() const body = (await res.json()) as HealthResponse expect(body.ensemble).toBe(true) expect(typeof body.pid).toBe("number") }) + + test("rejects a hostile Host header without serving dashboard data", async () => { + server = await startDashboard(db, port) + const response = await fetch(`http://127.0.0.1:${port}/api/health`, { + headers: { Host: `attacker.example:${port}` }, + }) + expect(response.status).toBe(421) + expect(response.headers.get("access-control-allow-origin")).toBeNull() + }) + + test("accepts localhost and loopback Host headers", async () => { + server = await startDashboard(db, port) + for (const host of [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`]) { + const response = await fetch(`http://127.0.0.1:${port}/api/health`, { headers: { Host: host } }) + expect(response.status).toBe(200) + } + }) }) describe("GET /api/state", () => { @@ -61,7 +78,7 @@ describe("dashboard", () => { server = await startDashboard(db, port) const res = await fetch(`http://localhost:${port}/api/state`) expect(res.status).toBe(200) - expect(res.headers.get("access-control-allow-origin")).toBe("*") + expect(res.headers.get("access-control-allow-origin")).toBeNull() const body = (await res.json()) as StateResponse expect(body).toEqual({ version: 1, projects: [], teams: [] }) }) From 25e6424f8ad3440bed46f78fbe6fd0de67cde440 Mon Sep 17 00:00:00 2001 From: Morteza PRK Date: Sun, 25 Oct 2026 01:15:25 +0200 Subject: [PATCH 2/2] feat: configure dashboard bind address --- README.md | 7 +++++-- src/config.ts | 4 ++++ src/dashboard.ts | 22 +++------------------- src/index.ts | 2 +- src/v2-setup.ts | 2 +- test/config.test.ts | 8 ++++++++ test/dashboard.test.ts | 19 ++++--------------- 7 files changed, 26 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index e9612f8..75b1e0a 100644 --- a/README.md +++ b/README.md @@ -158,11 +158,12 @@ Configure the port in `.opencode/ensemble.json`: ```json { - "dashboardPort": 4747 + "dashboardPort": 4747, + "dashboardHost": "127.0.0.1" } ``` -Set to `0` to disable. The dashboard starts automatically when OpenCode loads the plugin. +Set `dashboardPort` to `0` to disable. The dashboard starts automatically when OpenCode loads the plugin. `dashboardHost` defaults to loopback; changing it can expose dashboard data to other machines on your network. ## Install @@ -393,6 +394,7 @@ Configure via JSON files, environment variables, or both. Project config overrid "timeoutMs": 1800000, "rateLimitCapacity": 10, "dashboardPort": 4747, + "dashboardHost": "127.0.0.1", "defaultModel": "anthropic/claude-sonnet-4-6", "modelPool": ["anthropic/claude-opus-4-7", "anthropic/claude-sonnet-4-6", "openai/gpt-5.4"], "modelsByAgent": {}, @@ -414,6 +416,7 @@ All fields are optional. Missing fields use defaults. | `timeoutMs` | `1800000` (30 min) | Hard timeout for busy teammates. `0` disables. | | `rateLimitCapacity` | `10` | Token bucket capacity for team tool calls. `0` disables. | | `dashboardPort` | `4747` | Dashboard server port. `0` disables. | +| `dashboardHost` | `127.0.0.1` | Dashboard network interface to bind. Keep the loopback default unless you intend to expose the dashboard on your network. | | `defaultModel` | `""` | Default model for all agents (e.g. `"anthropic/claude-sonnet-4-6"`). Empty = OpenCode's default. | | `modelPool` | `[]` | List of models for rotation/random assignment. | | `modelsByAgent` | `{}` | Map agent type to model (e.g. `{"build": "anthropic/claude-opus-4-7"}`). | diff --git a/src/config.ts b/src/config.ts index 5ef6a75..bce573a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,8 @@ export interface EnsembleConfig { rateLimitCapacity?: number /** Dashboard server port (default: 4747, 0 to disable) */ dashboardPort?: number + /** Dashboard bind address (default: 127.0.0.1) */ + dashboardHost?: string /** Max peer messages per agent per window before nudge (default: 5, 0 to disable) */ peerMessageLimit?: number /** Time window for peer message rate limiting in ms (default: 300000 = 5 min) */ @@ -42,6 +44,7 @@ export const DEFAULT_CONFIG: Required = { timeoutMs: 30 * 60 * 1000, rateLimitCapacity: 10, dashboardPort: 4747, + dashboardHost: "127.0.0.1", peerMessageLimit: 5, peerMessageWindowMs: 300_000, defaultModel: "", @@ -65,6 +68,7 @@ function readConfigFile(filePath: string): Partial { if (typeof raw.timeoutMs === "number") result.timeoutMs = raw.timeoutMs if (typeof raw.rateLimitCapacity === "number") result.rateLimitCapacity = raw.rateLimitCapacity if (typeof raw.dashboardPort === "number") result.dashboardPort = raw.dashboardPort + if (typeof raw.dashboardHost === "string") result.dashboardHost = raw.dashboardHost if (typeof raw.peerMessageLimit === "number") result.peerMessageLimit = raw.peerMessageLimit if (typeof raw.peerMessageWindowMs === "number") result.peerMessageWindowMs = raw.peerMessageWindowMs if (typeof raw.defaultModel === "string") result.defaultModel = raw.defaultModel diff --git a/src/dashboard.ts b/src/dashboard.ts index 774299f..d39c544 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -207,6 +207,8 @@ export interface DashboardServer { /** Optional dependencies for the dashboard server. */ export interface DashboardOptions { + /** Network interface to bind (defaults to loopback). */ + host?: string /** In-memory activity buffer for real-time per-session events. */ activityBuffer?: ActivityBuffer /** SDK client for on-demand session message retrieval. */ @@ -334,11 +336,6 @@ function handleDashboardRequest( res: ServerResponse, options?: DashboardOptions, ): void { - if (!isAllowedHost(req.headers.host, port)) { - res.writeHead(421, { "Content-Type": "text/plain" }) - res.end("Misdirected Request") - return - } const url = new URL(req.url ?? "/", `http://${req.headers.host ?? `localhost:${port}`}`) if (url.pathname === "/api/health") { @@ -373,19 +370,6 @@ function handleDashboardRequest( res.end("Not Found") } -/** Accept only localhost and loopback Host values for this listener. */ -function isAllowedHost(host: string | undefined, port: number): boolean { - if (!host) return false - const normalized = host.toLowerCase() - const validHosts = new Set(["localhost", "127.0.0.1", "[::1]"]) - try { - const parsed = new URL(`http://${normalized}`) - return validHosts.has(parsed.hostname) && parsed.port === String(port) && parsed.username === "" && parsed.password === "" - } catch { - return false - } -} - function toDashboardServer(server: Server): DashboardServer { return { stop(force?: boolean) { @@ -441,7 +425,7 @@ export async function startDashboard(db: Database, port: number, options?: Dashb resolve(null) }) - server.listen(port, "127.0.0.1", () => { + server.listen(port, options?.host ?? "127.0.0.1", () => { log(`dashboard:started port=${port} url=http://localhost:${port}`) resolve(toDashboardServer(server)) }) diff --git a/src/index.ts b/src/index.ts index 0ab358f..0b413a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -131,7 +131,7 @@ const plugin: Plugin = async (input) => { // Start dashboard server (main instance only, not worktree instances) if (config.dashboardPort !== 0) { - startDashboard(db, config.dashboardPort, { activityBuffer, client }).catch((err) => { + startDashboard(db, config.dashboardPort, { activityBuffer, client, host: config.dashboardHost }).catch((err) => { log(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`) }) } diff --git a/src/v2-setup.ts b/src/v2-setup.ts index 77285bd..b043cb4 100644 --- a/src/v2-setup.ts +++ b/src/v2-setup.ts @@ -343,7 +343,7 @@ export async function setupEnsemble( let dashboard: DashboardServer | null = null const dashboardPort = options.dashboardPort ?? config.dashboardPort if (dashboardPort !== 0 && !isWorktreeInstance(ctx.location.directory)) { - dashboard = await startDashboard(db, dashboardPort, { activityBuffer, client }).catch((err) => { + dashboard = await startDashboard(db, dashboardPort, { activityBuffer, client, host: config.dashboardHost }).catch((err) => { vlog(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`) return null }) diff --git a/test/config.test.ts b/test/config.test.ts index cffa266..4f6675e 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -63,6 +63,14 @@ describe("config", () => { expect(config.stallThresholdMs).toBe(300_000) // default preserved }) + test("defaults dashboardHost to loopback and loads configured host", () => { + expect(DEFAULT_CONFIG.dashboardHost).toBe("127.0.0.1") + const configDir = path.join(tmpDir, ".opencode") + mkdirSync(configDir, { recursive: true }) + writeFileSync(path.join(configDir, "ensemble.json"), JSON.stringify({ dashboardHost: "0.0.0.0" })) + expect(loadConfig(tmpDir).dashboardHost).toBe("0.0.0.0") + }) + test("invalid JSON logs warning and returns defaults", () => { const configDir = path.join(tmpDir, ".opencode") mkdirSync(configDir, { recursive: true }) diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts index fb0dfa0..825d353 100644 --- a/test/dashboard.test.ts +++ b/test/dashboard.test.ts @@ -55,21 +55,10 @@ describe("dashboard", () => { expect(typeof body.pid).toBe("number") }) - test("rejects a hostile Host header without serving dashboard data", async () => { - server = await startDashboard(db, port) - const response = await fetch(`http://127.0.0.1:${port}/api/health`, { - headers: { Host: `attacker.example:${port}` }, - }) - expect(response.status).toBe(421) - expect(response.headers.get("access-control-allow-origin")).toBeNull() - }) - - test("accepts localhost and loopback Host headers", async () => { - server = await startDashboard(db, port) - for (const host of [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`]) { - const response = await fetch(`http://127.0.0.1:${port}/api/health`, { headers: { Host: host } }) - expect(response.status).toBe(200) - } + test("binds to the configured interface", async () => { + server = await startDashboard(db, port, { host: "127.0.0.2" }) + const response = await fetch(`http://127.0.0.2:${port}/api/health`) + expect(response.status).toBe(200) }) })