#!/usr/bin/env node /** * Automated Testing Script for DashCaddy Security Fixes * * Tests all implemented security improvements: * 1. Path traversal protection * 2. Request size limits * 3. Startup validation * 4. Port locking * 5. Session management (LRU cache) * 6. Enhanced error logging * 7. Hardcoded secrets removal */ const http = require('http'); const https = require('https'); const crypto = require('crypto'); const API_BASE = process.env.API_BASE || 'http://localhost:3001'; const TEST_RESULTS = []; // Color codes for terminal output const colors = { reset: '\x1b[0m', green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', }; function log(message, color = 'reset') { console.log(`${colors[color]}${message}${colors.reset}`); } function logTest(name) { console.log(`\n${colors.cyan}━━━ Testing: ${name} ━━━${colors.reset}`); } function logResult(passed, message) { const icon = passed ? '✓' : '✗'; const color = passed ? 'green' : 'red'; log(` ${icon} ${message}`, color); TEST_RESULTS.push({ passed, message }); } async function makeRequest(path, options = {}) { return new Promise((resolve, reject) => { const url = new URL(path, API_BASE); const isHttps = url.protocol === 'https:'; const client = isHttps ? https : http; const requestOptions = { hostname: url.hostname, port: url.port || (isHttps ? 443 : 80), path: url.pathname + url.search, method: options.method || 'GET', headers: options.headers || {}, ...options, }; const req = client.request(requestOptions, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { resolve({ statusCode: res.statusCode, headers: res.headers, body: data, data: data ? (data.startsWith('{') || data.startsWith('[') ? JSON.parse(data) : data) : null, }); }); }); req.on('error', reject); if (options.body) { req.write(typeof options.body === 'string' ? options.body : JSON.stringify(options.body)); } req.end(); }); } // Test 1: Path Traversal Protection async function testPathTraversal() { logTest('Path Traversal Protection'); const attacks = [ { path: '/api/browse/directories?path=../../../../../../etc/passwd', desc: 'Unix path traversal' }, { path: '/api/browse/directories?path=..\\..\\..\\Windows\\System32', desc: 'Windows path traversal' }, { path: '/api/browse/directories?path=%2e%2e%2f%2e%2e%2fetc%2fpasswd', desc: 'URL-encoded traversal' }, { path: '/api/browse/directories?path=/allowed/media/../../../secrets', desc: 'Mixed path traversal' }, ]; for (const attack of attacks) { try { const response = await makeRequest(attack.path); if (response.statusCode === 403 || response.statusCode === 400) { logResult(true, `Blocked: ${attack.desc}`); } else { logResult(false, `NOT BLOCKED (${response.statusCode}): ${attack.desc}`); } } catch (error) { logResult(false, `Error testing ${attack.desc}: ${error.message}`); } } } // Test 2: Request Size Limits async function testRequestSizeLimits() { logTest('Request Size Limits'); // Test 1: Small payload (should work) try { const smallPayload = { data: 'a'.repeat(100) }; const response = await makeRequest('/api/services', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(smallPayload), }); logResult(true, 'Small payload accepted (100 bytes)'); } catch (error) { logResult(false, `Small payload rejected: ${error.message}`); } // Test 2: Large payload on general endpoint (should fail) try { const largePayload = { data: 'a'.repeat(2 * 1024 * 1024) }; // 2MB const response = await makeRequest('/api/services', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(largePayload), }); if (response.statusCode === 413 || response.statusCode === 400) { logResult(true, 'Large payload rejected on general endpoint (2MB)'); } else { logResult(false, `Large payload NOT rejected (status: ${response.statusCode})`); } } catch (error) { if (error.message.includes('413') || error.message.includes('ECONNRESET')) { logResult(true, 'Large payload rejected (connection reset)'); } else { logResult(false, `Unexpected error: ${error.message}`); } } // Test 3: Large payload on logo endpoint (should work) try { const largeImage = 'a'.repeat(5 * 1024 * 1024); // 5MB const response = await makeRequest('/api/logo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ logo: largeImage }), }); if (response.statusCode !== 413) { logResult(true, 'Large payload accepted on logo endpoint (5MB)'); } else { logResult(false, 'Large payload rejected on logo endpoint'); } } catch (error) { // May fail for other reasons (auth, validation), but not size if (!error.message.includes('413')) { logResult(true, 'Logo endpoint accepts large payloads (failed for non-size reason)'); } else { logResult(false, `Logo endpoint rejected large payload: ${error.message}`); } } } // Test 3: Startup Validation async function testStartupValidation() { logTest('Startup Validation'); // Check if server is running (implies validation passed) try { const response = await makeRequest('/health'); if (response.statusCode === 200) { logResult(true, 'Server started successfully (validation passed)'); } else { logResult(false, `Server health check failed: ${response.statusCode}`); } } catch (error) { logResult(false, `Cannot reach server: ${error.message}`); } // Check for validation logs (requires access to logs) log(' → Check Docker logs for: "✓ Startup configuration validation passed"', 'yellow'); } // Test 4: Enhanced Error Logging (Request ID) async function testEnhancedLogging() { logTest('Enhanced Error Logging'); try { // Make a request that will be logged const response = await makeRequest('/api/services'); // Check if X-Request-ID header is present if (response.headers['x-request-id']) { const requestId = response.headers['x-request-id']; const isValidUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(requestId); if (isValidUUID) { logResult(true, `Request ID header present and valid: ${requestId.substring(0, 8)}...`); } else { logResult(false, `Request ID present but invalid format: ${requestId}`); } } else { logResult(false, 'Request ID header not present'); } } catch (error) { logResult(false, `Error testing logging: ${error.message}`); } } // Test 5: Session Management (LRU Cache) async function testSessionManagement() { logTest('Session Management (LRU Cache)'); log(' → This test requires code inspection (cannot test cache behavior externally)', 'yellow'); log(' → Manual verification: Check server.js for LRUCache usage', 'yellow'); // We can test that sessions still work try { const response = await makeRequest('/api/totp/setup', { method: 'POST' }); if (response.statusCode === 200 || response.statusCode === 401) { logResult(true, 'Session-based endpoints still functional'); } else { logResult(false, `Unexpected response from session endpoint: ${response.statusCode}`); } } catch (error) { logResult(false, `Error testing session endpoints: ${error.message}`); } } // Test 6: Hardcoded Secrets Removal async function testSecretsRemoval() { logTest('Hardcoded Secrets Removal'); try { // Read app-templates.js and check for "changeme123" const fs = require('fs'); const templatesPath = require('path').join(__dirname, 'app-templates.js'); const content = fs.readFileSync(templatesPath, 'utf8'); const matches = content.match(/changeme123/g); if (!matches || matches.length === 0) { logResult(true, 'No hardcoded "changeme123" passwords found'); } else { logResult(false, `Found ${matches.length} instances of "changeme123" still in templates`); } // Check for secrets arrays const secretsMatches = content.match(/secrets:\s*\[/g); if (secretsMatches && secretsMatches.length >= 10) { logResult(true, `Found ${secretsMatches.length} secrets configurations`); } else { logResult(false, `Only found ${secretsMatches?.length || 0} secrets configurations (expected 14+)`); } } catch (error) { logResult(false, `Error reading templates: ${error.message}`); } } // Test 7: Port Locking Mechanism async function testPortLocking() { logTest('Port Locking Mechanism'); try { // Check if .port-locks directory exists const fs = require('fs'); const path = require('path'); const locksDir = path.join(__dirname, '.port-locks'); if (fs.existsSync(locksDir)) { logResult(true, 'Port locks directory exists'); // Check if it's writable try { const testFile = path.join(locksDir, 'test-write'); fs.writeFileSync(testFile, 'test'); fs.unlinkSync(testFile); logResult(true, 'Port locks directory is writable'); } catch (error) { logResult(false, `Port locks directory not writable: ${error.message}`); } } else { logResult(false, 'Port locks directory does not exist'); } // Check if PortLockManager module exists const portLockPath = path.join(__dirname, 'port-lock-manager.js'); if (fs.existsSync(portLockPath)) { logResult(true, 'PortLockManager module exists'); } else { logResult(false, 'PortLockManager module not found'); } } catch (error) { logResult(false, `Error testing port locking: ${error.message}`); } } // Test 8: Docker Security Module async function testDockerSecurity() { logTest('Docker Image Verification'); try { const fs = require('fs'); const path = require('path'); // Check if docker-security.js exists const securityPath = path.join(__dirname, 'docker-security.js'); if (fs.existsSync(securityPath)) { logResult(true, 'DockerSecurity module exists'); } else { logResult(false, 'DockerSecurity module not found'); } // Check if config file exists const configPath = path.join(__dirname, 'docker-security-config.json'); if (fs.existsSync(configPath)) { const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); logResult(true, `Security config exists (mode: ${config.verificationMode || 'not set'})`); } else { log(' → Security config will be created on first use', 'yellow'); logResult(true, 'Config will be auto-created'); } } catch (error) { logResult(false, `Error testing Docker security: ${error.message}`); } } // Main test runner async function runTests() { log('\n╔════════════════════════════════════════════════════╗', 'cyan'); log('║ DashCaddy Security Fixes - Test Suite ║', 'cyan'); log('╚════════════════════════════════════════════════════╝', 'cyan'); log(`\nAPI Base URL: ${API_BASE}`, 'blue'); log('Starting tests...\n', 'blue'); // Run all tests await testStartupValidation(); await testPathTraversal(); await testRequestSizeLimits(); await testEnhancedLogging(); await testSessionManagement(); await testSecretsRemoval(); await testPortLocking(); await testDockerSecurity(); // Summary log('\n╔════════════════════════════════════════════════════╗', 'cyan'); log('║ Test Summary ║', 'cyan'); log('╚════════════════════════════════════════════════════╝', 'cyan'); const passed = TEST_RESULTS.filter(r => r.passed).length; const failed = TEST_RESULTS.filter(r => !r.passed).length; const total = TEST_RESULTS.length; log(`\nTotal Tests: ${total}`, 'blue'); log(`Passed: ${passed}`, 'green'); log(`Failed: ${failed}`, failed > 0 ? 'red' : 'green'); log(`Success Rate: ${((passed / total) * 100).toFixed(1)}%\n`, failed === 0 ? 'green' : 'yellow'); if (failed > 0) { log('Failed tests:', 'red'); TEST_RESULTS.filter(r => !r.passed).forEach(r => { log(` ✗ ${r.message}`, 'red'); }); } process.exit(failed > 0 ? 1 : 0); } // Run tests if executed directly if (require.main === module) { runTests().catch(error => { log(`\nFatal error: ${error.message}`, 'red'); console.error(error); process.exit(1); }); } module.exports = { runTests };