diff --git a/src/dashboard.mjs b/src/dashboard.mjs index 740357a..44e6834 100644 --- a/src/dashboard.mjs +++ b/src/dashboard.mjs @@ -7,6 +7,13 @@ import { fileURLToPath } from 'node:url'; import { DEFAULT_DASHBOARD_PORT } from './constants.mjs'; import { getDashboardAuth, parseTorchConfig } from './torch-config.mjs'; +const SECURITY_HEADERS = { + 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' wss:; object-src 'none'; base-uri 'self';", + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin' +}; + function timingSafeCompare(a, b) { const bufA = Buffer.from(a); const bufB = Buffer.from(b); @@ -30,7 +37,7 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. if (auth) { const authHeader = req.headers.authorization; if (!authHeader) { - res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' }); + res.writeHead(401, { ...SECURITY_HEADERS, 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' }); res.end('Authentication required'); return; } @@ -48,7 +55,7 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. } if (!isValid) { - res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' }); + res.writeHead(401, { ...SECURITY_HEADERS, 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' }); res.end('Invalid credentials'); return; } @@ -60,7 +67,7 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. // Redirect / to /dashboard/ if (pathname === '/' || pathname === '/dashboard') { - res.writeHead(302, { 'Location': '/dashboard/' }); + res.writeHead(302, { ...SECURITY_HEADERS, 'Location': '/dashboard/' }); res.end(); return; } @@ -111,17 +118,17 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. } delete safeConfig.configPath; - res.writeHead(200, { 'Content-Type': 'application/json' }); + res.writeHead(200, { ...SECURITY_HEADERS, '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.writeHead(500, SECURITY_HEADERS); res.end('Internal Server Error'); return; } } else { - res.writeHead(404); + res.writeHead(404, SECURITY_HEADERS); res.end('Not Found'); return; } @@ -146,7 +153,7 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. }); if (!isAllowed) { - res.writeHead(403); + res.writeHead(403, SECURITY_HEADERS); res.end('Forbidden'); return; } @@ -160,7 +167,7 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. // Check if file exists and is a file if (!fileStat || !fileStat.isFile()) { - res.writeHead(404); + res.writeHead(404, SECURITY_HEADERS); res.end('Not Found'); return; } @@ -181,12 +188,12 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. case '.md': contentType = 'text/markdown'; break; } - res.writeHead(200, { 'Content-Type': contentType }); + res.writeHead(200, { ...SECURITY_HEADERS, 'Content-Type': contentType }); fs.createReadStream(filePath).pipe(res); } catch (err) { console.error('Dashboard Server Error:', err); if (!res.headersSent) { - res.writeHead(500); + res.writeHead(500, SECURITY_HEADERS); res.end('Internal Server Error'); } } diff --git a/test/dashboard-security.test.mjs b/test/dashboard-security.test.mjs index 88391e8..5a177be 100644 --- a/test/dashboard-security.test.mjs +++ b/test/dashboard-security.test.mjs @@ -69,4 +69,36 @@ test('Dashboard File Access Security', async (t) => { // path.normalize collapses this to /package.json, which is then blocked assert.strictEqual(res.statusCode, 403); }); + + await t.test('responses include security headers', async () => { + const res = await get('/dashboard/index.html'); + assert.strictEqual(res.statusCode, 200); + + const headers = res.headers; + + // Content-Security-Policy + assert.ok(headers['content-security-policy'], 'CSP header missing'); + const csp = headers['content-security-policy']; + assert.match(csp, /default-src 'self'/, 'CSP: missing default-src self'); + assert.match(csp, /script-src 'self' 'unsafe-inline' https:\/\/cdn\.jsdelivr\.net/, 'CSP: missing script-src'); + assert.match(csp, /object-src 'none'/, 'CSP: missing object-src none'); + + // X-Content-Type-Options + assert.strictEqual(headers['x-content-type-options'], 'nosniff', 'X-Content-Type-Options mismatch'); + + // X-Frame-Options + assert.strictEqual(headers['x-frame-options'], 'DENY', 'X-Frame-Options mismatch'); + + // Referrer-Policy + assert.strictEqual(headers['referrer-policy'], 'strict-origin-when-cross-origin', 'Referrer-Policy mismatch'); + }); + + await t.test('error responses include security headers', async () => { + const res = await get('/dashboard/nonexistent.html'); + assert.strictEqual(res.statusCode, 404); + + const headers = res.headers; + assert.strictEqual(headers['x-content-type-options'], 'nosniff'); + assert.strictEqual(headers['x-frame-options'], 'DENY'); + }); });