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 <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-17 17:24:33 +00:00
1 parent 5196f264cb
commit 456bc2df18
7 files changed
+174 -8

No files matched your search

+3
View File
@@ -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') {
+52 -4
View File
@@ -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 <root>/src/dashboard.mjs, so '..' goes to <root>
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;
}
+2 -2
View File
@@ -500,7 +500,7 @@ Commands:
complete --agent <name> --cadence <daily|weekly> Mark task as completed (permanent)
list [--cadence <daily|weekly>] Print active lock table
health --cadence <daily|weekly> Probe relay websocket + publish/read health
dashboard [--port <port>] Serve the dashboard (default: ${DEFAULT_DASHBOARD_PORT})
dashboard [--port <port>] [--host <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;
}
+10
View File
@@ -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;
}