mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-30 01:12:05 +00:00
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:
1 parent
5196f264cb
commit
456bc2df18
7 files changed
+174
-8
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,8 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"defaultCadenceView": "daily",
|
||||
"defaultStatusView": "active"
|
||||
"defaultStatusView": "active",
|
||||
"auth": "admin:password"
|
||||
},
|
||||
"scheduler": {
|
||||
"firstPromptByCadence": {
|
||||
|
||||
Reference in new issue
Block a user