mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-08 13:52:16 +00:00
Merge pull request #229 from PR0M3TH3AN/fix/dashboard-config-security-7655777063695938949
🔒 Fix security vulnerability: Sanitize torch-config.json exposure
This commit is contained in:
2 files changed
+170
-9
No files matched your search
+49
-9
@@ -5,7 +5,7 @@ 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';
|
||||
import { getDashboardAuth, parseTorchConfig } from './torch-config.mjs';
|
||||
|
||||
function timingSafeCompare(a, b) {
|
||||
const bufA = Buffer.from(a);
|
||||
@@ -73,17 +73,57 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.
|
||||
}
|
||||
|
||||
// Special case: /torch-config.json
|
||||
// Priority: User's CWD > Package default
|
||||
// Priority: Env Var > User's CWD > Package default
|
||||
if (pathname === '/torch-config.json') {
|
||||
const userConfigPath = path.resolve(process.cwd(), 'torch-config.json');
|
||||
if (await statSafe(userConfigPath)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
fs.createReadStream(userConfigPath).pipe(res);
|
||||
let configContent = null;
|
||||
|
||||
if (process.env.TORCH_CONFIG_PATH) {
|
||||
const envPath = path.resolve(process.cwd(), process.env.TORCH_CONFIG_PATH);
|
||||
try {
|
||||
configContent = await fsp.readFile(envPath, 'utf8');
|
||||
} catch {
|
||||
// Explicitly provided config path not found, do not fall back
|
||||
}
|
||||
} else {
|
||||
const userConfigPath = path.resolve(process.cwd(), 'torch-config.json');
|
||||
try {
|
||||
configContent = await fsp.readFile(userConfigPath, 'utf8');
|
||||
} catch {
|
||||
// Try package default
|
||||
const packageConfigPath = path.join(packageRoot, 'torch-config.json');
|
||||
try {
|
||||
configContent = await fsp.readFile(packageConfigPath, 'utf8');
|
||||
} catch {
|
||||
// Not found in either location
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (configContent) {
|
||||
try {
|
||||
const rawConfig = JSON.parse(configContent);
|
||||
const safeConfig = parseTorchConfig(rawConfig);
|
||||
|
||||
// Security: Sanitize sensitive fields
|
||||
if (safeConfig.dashboard) {
|
||||
delete safeConfig.dashboard.auth;
|
||||
}
|
||||
delete safeConfig.configPath;
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(safeConfig, null, 2));
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error('Error parsing torch-config.json:', err);
|
||||
res.writeHead(500);
|
||||
res.end('Internal Server Error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
return;
|
||||
}
|
||||
// If not found in CWD, fall through to serve from packageRoot (if it exists there)
|
||||
// or return empty object if missing?
|
||||
// Falling through means it looks for packageRoot/torch-config.json
|
||||
}
|
||||
|
||||
// Security check: prevent directory traversal
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cmdDashboard } from '../src/dashboard.mjs';
|
||||
import { _resetTorchConfigCache } from '../src/torch-config.mjs';
|
||||
|
||||
const CONFIG_FILE = 'torch-config-test.json';
|
||||
const TEST_PORT = 4176;
|
||||
|
||||
// Helper to fetch JSON
|
||||
function fetchJson(url, auth) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
headers: {}
|
||||
};
|
||||
if (auth) {
|
||||
options.headers['Authorization'] = `Basic ${Buffer.from(auth).toString('base64')}`;
|
||||
}
|
||||
http.get(url, options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('Dashboard Configuration Security', async (t) => {
|
||||
process.env.TORCH_CONFIG_PATH = CONFIG_FILE;
|
||||
const configPath = path.resolve(process.cwd(), CONFIG_FILE);
|
||||
|
||||
// Ensure clean state
|
||||
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
||||
_resetTorchConfigCache();
|
||||
|
||||
t.after(() => {
|
||||
delete process.env.TORCH_CONFIG_PATH;
|
||||
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
||||
});
|
||||
|
||||
// Scenario 1: Unauthenticated access (no auth configured), sensitive fields in config
|
||||
await t.test('sanitizes config when auth is disabled', async () => {
|
||||
const sensitiveConfig = {
|
||||
dashboard: {
|
||||
relays: ["wss://public.relay.com"]
|
||||
},
|
||||
super_secret_key: "DO_NOT_LEAK",
|
||||
nostrLock: {
|
||||
relays: ["wss://lock.relay.com"]
|
||||
}
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(sensitiveConfig, null, 2));
|
||||
_resetTorchConfigCache();
|
||||
|
||||
const server = await cmdDashboard(TEST_PORT);
|
||||
|
||||
try {
|
||||
const { statusCode, body } = await fetchJson(`http://127.0.0.1:${TEST_PORT}/torch-config.json`);
|
||||
|
||||
assert.strictEqual(statusCode, 200);
|
||||
const json = JSON.parse(body);
|
||||
|
||||
// Check for leaks
|
||||
// BEFORE FIX: This will likely FAIL because super_secret_key is present
|
||||
if (json.super_secret_key) {
|
||||
console.log('WARNING: super_secret_key leaked (Expected failure before fix)');
|
||||
}
|
||||
assert.strictEqual(json.super_secret_key, undefined, 'Unknown fields should be stripped');
|
||||
|
||||
// Check for valid fields
|
||||
assert.deepStrictEqual(json.dashboard.relays, sensitiveConfig.dashboard.relays);
|
||||
assert.deepStrictEqual(json.nostrLock.relays, sensitiveConfig.nostrLock.relays);
|
||||
|
||||
// Check that configPath is not leaked
|
||||
assert.strictEqual(json.configPath, undefined, 'configPath should not be exposed');
|
||||
|
||||
} finally {
|
||||
server.close();
|
||||
// Wait for server close? cmdDashboard returns server.
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 2: Authenticated access, auth credentials in config
|
||||
await t.test('sanitizes auth credentials even when authenticated', async () => {
|
||||
const authConfig = {
|
||||
dashboard: {
|
||||
auth: "admin:password123",
|
||||
relays: ["wss://secure.relay.com"]
|
||||
}
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(authConfig, null, 2));
|
||||
_resetTorchConfigCache();
|
||||
|
||||
const TEST_PORT_AUTH = TEST_PORT + 1;
|
||||
const server = await cmdDashboard(TEST_PORT_AUTH);
|
||||
|
||||
try {
|
||||
// 1. Verify 401 without auth
|
||||
const res401 = await fetchJson(`http://127.0.0.1:${TEST_PORT_AUTH}/torch-config.json`);
|
||||
assert.strictEqual(res401.statusCode, 401);
|
||||
|
||||
// 2. Verify 200 with auth
|
||||
const { statusCode, body } = await fetchJson(`http://127.0.0.1:${TEST_PORT_AUTH}/torch-config.json`, "admin:password123");
|
||||
assert.strictEqual(statusCode, 200);
|
||||
|
||||
const json = JSON.parse(body);
|
||||
|
||||
// Check for leaks
|
||||
assert.strictEqual(json.dashboard.auth, undefined, 'Auth credentials should be stripped');
|
||||
|
||||
// Check for valid fields
|
||||
assert.deepStrictEqual(json.dashboard.relays, authConfig.dashboard.relays);
|
||||
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
Reference in new issue
Block a user