From 456bc2df18b7aa3cfbf8451d9c6d3b9104174fb4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:24:33 +0000 Subject: [PATCH] feat(security): add authentication and host binding to dashboard This commit addresses the missing authentication vulnerability in the dashboard server. Changes: - Added Basic Authentication support via TORCH_DASHBOARD_AUTH env var or dashboard.auth config field. - Defaulted dashboard host binding to 127.0.0.1 for better security. - Added --host CLI argument to allow overriding the bind address. - Used crypto.timingSafeEqual for secure credential comparison. - Fixed a potential crash when parsing malformed Authorization headers. - Added comprehensive unit tests for dashboard authentication. - Updated documentation in README.md and torch-config.example.json. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- README.md | 9 +++- src/cli-parser.mjs | 3 ++ src/dashboard.mjs | 56 +++++++++++++++++++-- src/lib.mjs | 4 +- src/torch-config.mjs | 10 ++++ test/dashboard-auth.test.mjs | 97 ++++++++++++++++++++++++++++++++++++ torch-config.example.json | 3 +- 7 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 test/dashboard-auth.test.mjs diff --git a/README.md b/README.md index fb53de1..b712e48 100644 --- a/README.md +++ b/README.md @@ -91,10 +91,16 @@ Configuration options: The dashboard provides a live view of lock events. ```bash -npx --no-install torch-lock dashboard --port 4173 +npx --no-install torch-lock dashboard --port 4173 --host 127.0.0.1 # Open http://localhost:4173/dashboard/ ``` +By default, the dashboard binds to `127.0.0.1`. To allow external access, you can set `--host 0.0.0.0`. It is highly recommended to enable authentication if the dashboard is exposed to a network. + +#### Authentication + +The dashboard supports Basic Authentication. You can enable it by setting the `TORCH_DASHBOARD_AUTH` environment variable or the `dashboard.auth` field in `torch-config.json` with the format `username:password`. + ## Environment variables You can override configuration using environment variables: @@ -107,6 +113,7 @@ You can override configuration using environment variables: - `NOSTR_LOCK_DAILY_ROSTER` (comma-separated list of agents) - `NOSTR_LOCK_WEEKLY_ROSTER` (comma-separated list of agents) - `TORCH_CONFIG_PATH` (path to config file) +- `TORCH_DASHBOARD_AUTH` (Basic Auth `user:pass` for the dashboard) - `AGENT_PLATFORM` (platform identifier, e.g. `codex`) - `TORCH_MEMORY_ENABLED` (`true`/`false`; global memory kill switch, defaults to enabled) - `TORCH_MEMORY_INGEST_ENABLED` (`true`/`false` or comma-separated canary `agent_id` allow list) diff --git a/src/cli-parser.mjs b/src/cli-parser.mjs index 2a83ee4..21e20b5 100644 --- a/src/cli-parser.mjs +++ b/src/cli-parser.mjs @@ -17,6 +17,7 @@ export function parseArgs(argv) { dryRun: false, force: false, port: DEFAULT_DASHBOARD_PORT, + host: null, logDir: 'task-logs', ignoreLogs: false, id: null, @@ -65,6 +66,8 @@ export function parseArgs(argv) { args.force = true; } else if (arg === '--port') { args.port = parseInt(argv[++i], 10) || DEFAULT_DASHBOARD_PORT; + } else if (arg === '--host') { + args.host = argv[++i]; } else if (arg === '--log-dir') { args.logDir = argv[++i]; } else if (arg === '--ignore-logs') { diff --git a/src/dashboard.mjs b/src/dashboard.mjs index 3474029..24e3d5f 100644 --- a/src/dashboard.mjs +++ b/src/dashboard.mjs @@ -2,15 +2,57 @@ import fs from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; import http from 'node:http'; +import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { DEFAULT_DASHBOARD_PORT } from './constants.mjs'; +import { getDashboardAuth } from './torch-config.mjs'; -export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT) { +function timingSafeCompare(a, b) { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) { + // Still do a comparison to avoid some timing leaks, though length leak is hard to avoid entirely + crypto.timingSafeEqual(bufA, bufA); + return false; + } + return crypto.timingSafeEqual(bufA, bufB); +} + +export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.0.1') { // Resolve package root relative to this file (src/dashboard.mjs) // this file is in /src/dashboard.mjs, so '..' goes to const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const auth = getDashboardAuth(); const server = http.createServer(async (req, res) => { + // Basic Auth check + if (auth) { + const authHeader = req.headers.authorization; + if (!authHeader) { + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' }); + res.end('Authentication required'); + return; + } + const parts = authHeader.split(' '); + const type = parts[0]; + const credentials = parts[1]; + let isValid = false; + if (type === 'Basic' && credentials) { + try { + const decoded = Buffer.from(credentials, 'base64').toString(); + isValid = timingSafeCompare(decoded, auth); + } catch { + isValid = false; + } + } + + if (!isValid) { + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' }); + res.end('Invalid credentials'); + return; + } + } + // URL parsing const url = new URL(req.url, `http://${req.headers.host}`); let pathname = url.pathname; @@ -82,12 +124,18 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT) { fs.createReadStream(filePath).pipe(res); }); - server.listen(port, () => { - console.log(`Dashboard running at http://localhost:${port}/dashboard/`); + server.listen(port, host, () => { + const listenUrl = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/dashboard/`; + console.log(`Dashboard running at ${listenUrl}`); + if (auth) { + console.log('Authentication: enabled (Basic Auth)'); + } else { + console.warn('Authentication: DISABLED (Dashboard is public)'); + } console.log(`Serving files from ${packageRoot}`); console.log(`Using configuration from ${process.cwd()}`); }); // Keep process alive - return new Promise(() => {}); + return server; } diff --git a/src/lib.mjs b/src/lib.mjs index 09d1985..d9febd4 100644 --- a/src/lib.mjs +++ b/src/lib.mjs @@ -500,7 +500,7 @@ Commands: complete --agent --cadence Mark task as completed (permanent) list [--cadence ] Print active lock table health --cadence Probe relay websocket + publish/read health - dashboard [--port ] Serve the dashboard (default: ${DEFAULT_DASHBOARD_PORT}) + dashboard [--port ] [--host ] Serve the dashboard (default: ${DEFAULT_DASHBOARD_PORT}) init [--force] Initialize torch/ directory in current project update [--force] Update torch/ configuration (backups, merges) @@ -632,7 +632,7 @@ export async function main(argv) { } case 'dashboard': { - await cmdDashboard(args.port); + await cmdDashboard(args.port, args.host); break; } diff --git a/src/torch-config.mjs b/src/torch-config.mjs index c967602..f48010f 100644 --- a/src/torch-config.mjs +++ b/src/torch-config.mjs @@ -116,6 +116,7 @@ export function parseTorchConfig(raw, configPath = null) { : null, namespace: typeof dashboard.namespace === 'string' ? dashboard.namespace.trim() : null, hashtag: typeof dashboard.hashtag === 'string' ? dashboard.hashtag.trim() : null, + auth: typeof dashboard.auth === 'string' ? dashboard.auth.trim() : null, }, scheduler: { firstPromptByCadence: { @@ -302,3 +303,12 @@ export function getHashtag() { const namespace = getNamespace(); return `${namespace}-agent-lock`; } + +export function getDashboardAuth() { + const config = loadTorchConfig(); + const envValue = process.env.TORCH_DASHBOARD_AUTH; + if (envValue) { + return envValue.trim(); + } + return config.dashboard.auth; +} diff --git a/test/dashboard-auth.test.mjs b/test/dashboard-auth.test.mjs new file mode 100644 index 0000000..3815e24 --- /dev/null +++ b/test/dashboard-auth.test.mjs @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import http from 'node:http'; +import { cmdDashboard } from '../src/dashboard.mjs'; +import { _resetTorchConfigCache } from '../src/torch-config.mjs'; + +test('Dashboard Authentication', async (t) => { + const testPort = 4174; + + await t.test('returns 401 when auth is required but missing', async () => { + process.env.TORCH_DASHBOARD_AUTH = 'admin:password'; + _resetTorchConfigCache(); + + const server = await cmdDashboard(testPort); + + try { + const res = await new Promise((resolve) => { + http.get(`http://127.0.0.1:${testPort}/dashboard/`, resolve); + }); + + assert.strictEqual(res.statusCode, 401); + assert.strictEqual(res.headers['www-authenticate'], 'Basic realm="TORCH Dashboard"'); + } finally { + server.close(); + delete process.env.TORCH_DASHBOARD_AUTH; + _resetTorchConfigCache(); + } + }); + + await t.test('returns 401 when malformed auth header is provided', async () => { + process.env.TORCH_DASHBOARD_AUTH = 'admin:password'; + _resetTorchConfigCache(); + + const server = await cmdDashboard(testPort); + + try { + const res = await new Promise((resolve) => { + http.get({ + host: '127.0.0.1', + port: testPort, + path: '/dashboard/', + headers: { 'Authorization': 'Basic' } + }, resolve); + }); + + assert.strictEqual(res.statusCode, 401); + } finally { + server.close(); + delete process.env.TORCH_DASHBOARD_AUTH; + _resetTorchConfigCache(); + } + }); + + await t.test('returns 200 when valid credentials are provided', async () => { + process.env.TORCH_DASHBOARD_AUTH = 'admin:password'; + _resetTorchConfigCache(); + + const server = await cmdDashboard(testPort); + + try { + const auth = Buffer.from('admin:password').toString('base64'); + const res = await new Promise((resolve) => { + http.get({ + host: '127.0.0.1', + port: testPort, + path: '/dashboard/', + headers: { 'Authorization': `Basic ${auth}` } + }, resolve); + }); + + // We expect 200 (if dashboard/index.html exists) or 404 (if not), but not 401 + assert.notStrictEqual(res.statusCode, 401); + } finally { + server.close(); + delete process.env.TORCH_DASHBOARD_AUTH; + _resetTorchConfigCache(); + } + }); + + await t.test('returns 200 when auth is disabled', async () => { + delete process.env.TORCH_DASHBOARD_AUTH; + _resetTorchConfigCache(); + + const server = await cmdDashboard(testPort); + + try { + const res = await new Promise((resolve) => { + http.get(`http://127.0.0.1:${testPort}/dashboard/`, resolve); + }); + + assert.notStrictEqual(res.statusCode, 401); + } finally { + server.close(); + _resetTorchConfigCache(); + } + }); +}); diff --git a/torch-config.example.json b/torch-config.example.json index 1a00ac5..61573e6 100644 --- a/torch-config.example.json +++ b/torch-config.example.json @@ -18,7 +18,8 @@ }, "dashboard": { "defaultCadenceView": "daily", - "defaultStatusView": "active" + "defaultStatusView": "active", + "auth": "admin:password" }, "scheduler": { "firstPromptByCadence": {