Merge krystie-improvements into main

Resolves 24 conflicts between Hermes (DC-008/009/010 + response-helper
envelope standardization) and Krystie (DC-005 src/ refactor path fixes,
DC-006 TOTP integration, DC-007 new test suites, cloud backup
destinations).

Conflict resolutions:
- src/utils/logging.js:    took ours (consumers depend on logError/
                            safeErrorMessage/createLogger exports)
- src/config/site.js:      merged (her factored validateAndLogConfig +
                            applyConfigFields helpers)
- src/context/dns.js:      took hers (admin/readonly role iteration for
                            write operations)
- src/utilities/backup-
  manager.js:              took hers (Dropbox/WebDAV/SFTP cloud feature)
- status/dist/*, status/
  sw.js:                   took hers (minified bundles + newer SW cache)

Additional fix (post-merge regression):
- src/monitoring/health-checker.js: fixed DC-005 path miss —
  'require(./platform-paths)' → 'require(../../platform-paths)'

Test status: 921/922 passing. One known failure in logging.test.js
(async file-handle timing) tracked as follow-up.
This commit is contained in:
Hermes
2026-06-25 16:43:10 -07:00
171 changed files with 11759 additions and 1006 deletions
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
Fix the remaining broken require paths after DC-005 refactor.
Two patterns to fix:
1. `require('./src/...')` and `require('../../src/...')` and `require('../../../src/...')`
in files inside `src/` directories → should be `require('../...')` (relative to src/)
2. `require('../../../src/...')` in test files in `__tests__/` → should be `require('../src/...')`
"""
import os
import re
from pathlib import Path
DASHCADDY_API = Path('/root/dashcaddy-krystie/dashcaddy-api')
# Pattern to match require('../../../src/X/Y') and capture
# We need to detect the file's location and rewrite based on that
# A simple approach: find any require that contains 'src/' in the path,
# and rewrite it to be relative to the file's location.
def fix_file(filepath: Path) -> bool:
"""Returns True if file was changed."""
content = filepath.read_text()
original = content
# Find the file's directory relative to dashcaddy-api root
rel_dir = filepath.parent.relative_to(DASHCADDY_API)
depth = len(rel_dir.parts)
# If file is in src/X/Y/file.js, depth is 3 (src, X, Y)
# If file is in __tests__/file.js, depth is 1
# If file is in __tests__/routes/file.js, depth is 2
# Find all require() calls that contain 'src/'
# Pattern: require('(.....)*src/path')
def replacer(match):
quote = match.group(1) # the quote char
path = match.group(2) # the path inside quotes
# Calculate what the path SHOULD be
if 'src/' not in path:
return match.group(0)
# Extract the part after 'src/'
idx = path.find('src/')
after_src = path[idx + 4:] # everything after 'src/'
if filepath.parts[-3] == 'src':
# File is in src/X/file.js - depth 3
# Should be '../<after_src>'
new_path = '../' + after_src
elif filepath.parts[-4] == 'src':
# File is in src/X/Y/file.js - depth 4
# Should be '../../<after_src>'
new_path = '../../' + after_src
elif filepath.parts[-2] == '__tests__' or filepath.parent.name == '__tests__':
# File is in __tests__/file.js - depth 1 (relative to api root)
# Should be '../src/<after_src>'
new_path = '../src/' + after_src
elif filepath.parts[-2] == 'routes' and filepath.parts[-3] == '__tests__':
# File is in __tests__/routes/file.js - depth 2
# Should be '../../src/<after_src>'
new_path = '../../src/' + after_src
elif 'src' in rel_dir.parts:
# Other src nested location
# Count how many .. we need
src_depth = len(rel_dir.parts) - list(rel_dir.parts).index('src') - 1
new_path = '../' * src_depth + after_src
else:
# Other location, leave it
return match.group(0)
return f"require({quote}{new_path}{quote})"
new_content = re.sub(
r"require\((['\"])([^'\"]*src/[^'\"]*)\1\)",
replacer,
content
)
if new_content != original:
filepath.write_text(new_content)
return True
return False
def main():
changed = []
for js_file in DASHCADDY_API.rglob('*.js'):
# Skip node_modules
if 'node_modules' in js_file.parts:
continue
if fix_file(js_file):
changed.append(str(js_file.relative_to(DASHCADDY_API)))
print(f"Changed {len(changed)} files:")
for f in changed:
print(f" {f}")
if __name__ == '__main__':
main()
@@ -0,0 +1,489 @@
#!/usr/bin/env node
/**
* Comprehensive DashCaddy Security Test Suite
* Tests all 11 security fixes with detailed verification
*/
const http = require('http');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const API_BASE = process.env.API_BASE || 'http://localhost:3001';
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
magenta: '\x1b[35m'
};
const testResults = {
passed: 0,
failed: 0,
warnings: 0,
total: 0,
details: []
};
function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
function logSection(title) {
console.log(`\n${colors.cyan}${'═'.repeat(60)}${colors.reset}`);
console.log(`${colors.cyan} ${title}${colors.reset}`);
console.log(`${colors.cyan}${'═'.repeat(60)}${colors.reset}\n`);
}
function recordTest(name, passed, message, warning = false) {
testResults.total++;
if (warning) {
testResults.warnings++;
log(`${name}: ${message}`, 'yellow');
} else if (passed) {
testResults.passed++;
log(`${name}: ${message}`, 'green');
} else {
testResults.failed++;
log(`${name}: ${message}`, 'red');
}
testResults.details.push({ name, passed, message, warning });
}
async function makeRequest(path, options = {}) {
return new Promise((resolve, reject) => {
const url = new URL(path, API_BASE);
const requestOptions = {
hostname: url.hostname,
port: url.port || 80,
path: url.pathname + url.search,
method: options.method || 'GET',
headers: options.headers || {},
timeout: options.timeout || 10000
};
const req = http.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('[')) ?
(() => { try { return JSON.parse(data); } catch(e) { return null; } })() : data
});
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timeout'));
});
if (options.body) {
req.write(typeof options.body === 'string' ? options.body : JSON.stringify(options.body));
}
req.end();
});
}
// Test 1: Startup Validation & Health Checks
async function testStartupValidation() {
logSection('TEST 1: Startup Validation & Health Checks');
try {
const response = await makeRequest('/health');
if (response.statusCode === 200 && response.data?.status === 'ok') {
recordTest('Health Endpoint', true, `Server healthy (${response.data.timestamp})`);
} else {
recordTest('Health Endpoint', false, `Unexpected response: ${response.statusCode}`);
}
} catch (error) {
recordTest('Health Endpoint', false, `Error: ${error.message}`);
}
// Check for startup validation in logs (requires Docker access)
log('\n Manual check: Run "docker logs dashcaddy-api | grep validation"', 'yellow');
log(' Expected: "✓ Startup configuration validation passed"', 'yellow');
}
// Test 2: CSRF Protection
async function testCSRFProtection() {
logSection('TEST 2: CSRF Protection');
// Test 2a: CSRF cookie is set
try {
const response = await makeRequest('/api/services');
const csrfCookie = response.headers['set-cookie']?.find(c => c.includes('dashcaddy_csrf'));
if (csrfCookie) {
const hasMaxAge = csrfCookie.includes('Max-Age');
const hasSameSite = csrfCookie.includes('SameSite=Strict');
if (hasMaxAge && hasSameSite) {
recordTest('CSRF Cookie', true, 'Cookie set with correct attributes (Max-Age, SameSite=Strict)');
} else {
recordTest('CSRF Cookie', true, 'Cookie set but missing some attributes', true);
}
} else {
recordTest('CSRF Cookie', false, 'CSRF cookie not set in response');
}
} catch (error) {
recordTest('CSRF Cookie', false, `Error: ${error.message}`);
}
// Test 2b: POST without CSRF token is blocked
try {
const response = await makeRequest('/api/test-endpoint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { test: 'data' }
});
if (response.data?.error?.includes('CSRF') || response.data?.message?.includes('CSRF')) {
recordTest('CSRF Validation', true, 'POST blocked without CSRF token');
} else if (response.statusCode === 401) {
recordTest('CSRF Validation', true, 'Request requires authentication (CSRF check bypassed)', true);
} else {
recordTest('CSRF Validation', false, `Unexpected: ${JSON.stringify(response.data)}`);
}
} catch (error) {
recordTest('CSRF Validation', false, `Error: ${error.message}`);
}
// Test 2c: CSRF token endpoint (may require auth)
try {
const response = await makeRequest('/api/csrf-token');
if (response.statusCode === 200 && response.data?.token) {
recordTest('CSRF Token Endpoint', true, 'Token endpoint returns valid token');
} else if (response.statusCode === 401) {
recordTest('CSRF Token Endpoint', true, 'Endpoint requires authentication (expected with TOTP)', true);
} else {
recordTest('CSRF Token Endpoint', false, `Unexpected response: ${response.statusCode}`);
}
} catch (error) {
recordTest('CSRF Token Endpoint', false, `Error: ${error.message}`);
}
}
// Test 3: Request Size Limits
async function testRequestSizeLimits() {
logSection('TEST 3: Request Size Limits');
// Test 3a: 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)
});
if (response.statusCode !== 413) {
recordTest('Small Payload', true, `Accepted (${response.statusCode})`);
} else {
recordTest('Small Payload', false, 'Small payload rejected as too large');
}
} catch (error) {
if (!error.message.includes('413')) {
recordTest('Small Payload', true, 'Accepted (non-size error)');
} else {
recordTest('Small Payload', false, `Rejected: ${error.message}`);
}
}
// Test 3b: Check if large payloads are rejected (without actually sending 2MB)
log('\n Info: Testing large payload rejection requires actual 2MB POST', 'blue');
log(' Expected behavior: Payloads > 1MB rejected with 413', 'blue');
recordTest('Large Payload Rejection', true, 'Mechanism in place (verified in logs)', true);
}
// Test 4: Enhanced Error Logging
async function testErrorLogging() {
logSection('TEST 4: Enhanced Error Logging (Request IDs)');
try {
const response = await makeRequest('/api/services');
const requestId = response.headers['x-request-id'];
if (requestId) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (uuidRegex.test(requestId)) {
recordTest('Request ID Header', true, `Valid UUID: ${requestId.substring(0, 13)}...`);
} else {
recordTest('Request ID Header', false, `Invalid UUID format: ${requestId}`);
}
} else {
recordTest('Request ID Header', false, 'X-Request-ID header not present');
}
} catch (error) {
recordTest('Request ID Header', false, `Error: ${error.message}`);
}
log('\n Manual check: Error logs should include IP, User-Agent, Method, Path', 'yellow');
log(' Run: docker logs dashcaddy-api | grep -i "error" | tail -5', 'yellow');
}
// Test 5: Authentication Layer
async function testAuthentication() {
logSection('TEST 5: Authentication Layer');
// Test 5a: Auth endpoints exist
try {
const response = await makeRequest('/api/auth/keys');
if (response.statusCode === 401) {
recordTest('Auth Endpoints', true, 'Auth required (TOTP enabled)');
} else if (response.statusCode === 200) {
recordTest('Auth Endpoints', true, 'Endpoint accessible (TOTP disabled)', true);
} else {
recordTest('Auth Endpoints', false, `Unexpected status: ${response.statusCode}`);
}
} catch (error) {
recordTest('Auth Endpoints', false, `Error: ${error.message}`);
}
// Test 5b: Check AuthManager in logs
log('\n Manual check: Verify AuthManager initialized', 'yellow');
log(' Run: docker logs dashcaddy-api | grep AuthManager', 'yellow');
log(' Expected: "[AuthManager] Initialized"', 'yellow');
}
// Test 6: Port Locking
async function testPortLocking() {
logSection('TEST 6: Port Locking Mechanism');
log(' Manual check: Port lock directory created in container', 'yellow');
log(' Run: docker logs dashcaddy-api | grep PortLockManager', 'yellow');
log(' Expected: "[PortLockManager] Created lock directory: /app/.port-locks"', 'yellow');
log(' Expected: "[PortLockManager] Cleanup complete: X stale locks removed"', 'yellow');
// Check if module exists locally
const modulePath = path.join(__dirname, 'port-lock-manager.js');
if (fs.existsSync(modulePath)) {
recordTest('Port Lock Module', true, 'port-lock-manager.js exists');
} else {
recordTest('Port Lock Module', false, 'port-lock-manager.js not found');
}
}
// Test 7: Docker Security Module
async function testDockerSecurity() {
logSection('TEST 7: Docker Image Verification');
const modulePath = path.join(__dirname, 'docker-security.js');
if (fs.existsSync(modulePath)) {
recordTest('Docker Security Module', true, 'docker-security.js exists');
} else {
recordTest('Docker Security Module', false, 'docker-security.js not found');
}
log('\n Manual check: Docker security initialized', 'yellow');
log(' Run: docker logs dashcaddy-api | grep DockerSecurity', 'yellow');
log(' Expected: "[DockerSecurity] Initialized in verify mode"', 'yellow');
}
// Test 8: Hardcoded Secrets Removal
async function testSecretsRemoval() {
logSection('TEST 8: Hardcoded Secrets Removal');
try {
const templatesPath = path.join(__dirname, 'app-templates.js');
const content = fs.readFileSync(templatesPath, 'utf8');
const changeMe123 = (content.match(/changeme123/g) || []).length;
const secretsConfigs = (content.match(/secrets:\s*\[/g) || []).length;
if (changeMe123 === 0) {
recordTest('Hardcoded Secrets', true, 'No "changeme123" found in templates');
} else {
recordTest('Hardcoded Secrets', false, `Found ${changeMe123} instances of "changeme123"`);
}
if (secretsConfigs >= 10) {
recordTest('Secrets Configurations', true, `Found ${secretsConfigs} secrets configs`);
} else {
recordTest('Secrets Configurations', false, `Only ${secretsConfigs} configs (expected 14+)`);
}
} catch (error) {
recordTest('Hardcoded Secrets', false, `Error reading templates: ${error.message}`);
}
}
// Test 9: LRU Cache Implementation
async function testLRUCache() {
logSection('TEST 9: Session Management (LRU Cache)');
// Check if cache-config exists
const cacheConfigPath = path.join(__dirname, 'cache-config.js');
if (fs.existsSync(cacheConfigPath)) {
recordTest('LRU Cache Module', true, 'cache-config.js exists');
try {
const content = fs.readFileSync(cacheConfigPath, 'utf8');
if (content.includes('LRUCache')) {
recordTest('LRU Implementation', true, 'Uses LRUCache from lru-cache package');
} else {
recordTest('LRU Implementation', false, 'LRUCache not found in cache-config.js');
}
} catch (error) {
recordTest('LRU Implementation', false, `Error: ${error.message}`);
}
} else {
recordTest('LRU Cache Module', false, 'cache-config.js not found');
}
// Check server.js for cache usage
try {
const serverPath = path.join(__dirname, 'server.js');
const content = fs.readFileSync(serverPath, 'utf8');
const cacheUsage = (content.match(/createCache\(/g) || []).length;
if (cacheUsage >= 4) {
recordTest('Cache Usage', true, `Found ${cacheUsage} cache instances in server.js`);
} else {
recordTest('Cache Usage', false, `Only ${cacheUsage} instances (expected 4+)`);
}
} catch (error) {
recordTest('Cache Usage', false, `Error: ${error.message}`);
}
}
// Test 10: Frontend CSRF Integration
async function testFrontendCSRF() {
logSection('TEST 10: Frontend CSRF Integration');
try {
const indexPath = path.join(__dirname, '..', 'status', 'index.html');
if (!fs.existsSync(indexPath)) {
recordTest('Frontend File', false, 'index.html not found');
return;
}
const content = fs.readFileSync(indexPath, 'utf8');
// Check for CSRF helper functions
if (content.includes('getCSRFToken') && content.includes('secureFetch')) {
recordTest('CSRF Helpers', true, 'getCSRFToken() and secureFetch() found');
} else {
recordTest('CSRF Helpers', false, 'CSRF helper functions not found');
}
// Check for secureFetch usage
const secureFetchUsage = (content.match(/secureFetch\(/g) || []).length;
if (secureFetchUsage >= 30) {
recordTest('Frontend Integration', true, `${secureFetchUsage} secureFetch calls found`);
} else {
recordTest('Frontend Integration', false, `Only ${secureFetchUsage} calls (expected 30+)`);
}
} catch (error) {
recordTest('Frontend CSRF', false, `Error: ${error.message}`);
}
}
// Test 11: Path Traversal Protection
async function testPathTraversal() {
logSection('TEST 11: Path Traversal Protection');
// Check if validateSecurePath exists in input-validator
try {
const validatorPath = path.join(__dirname, 'input-validator.js');
const content = fs.readFileSync(validatorPath, 'utf8');
if (content.includes('validateSecurePath')) {
recordTest('Path Validation Function', true, 'validateSecurePath() found in input-validator.js');
if (content.includes('fs.promises.realpath') || content.includes('realpath')) {
recordTest('Realpath Implementation', true, 'Uses fs.realpath() for symlink resolution');
} else {
recordTest('Realpath Implementation', false, 'Does not use realpath()');
}
} else {
recordTest('Path Validation Function', false, 'validateSecurePath() not found');
}
} catch (error) {
recordTest('Path Traversal Protection', false, `Error: ${error.message}`);
}
log('\n Note: Path traversal endpoints require authentication to test', 'yellow');
}
// Main test runner
async function runAllTests() {
log('\n╔════════════════════════════════════════════════════════════╗', 'magenta');
log('║ DashCaddy Comprehensive Security Test Suite ║', 'magenta');
log('╚════════════════════════════════════════════════════════════╝', 'magenta');
log(`\nAPI Base: ${API_BASE}`, 'blue');
log(`Test Time: ${new Date().toISOString()}`, 'blue');
log('\nRunning comprehensive security tests...\n', 'blue');
await testStartupValidation();
await testCSRFProtection();
await testRequestSizeLimits();
await testErrorLogging();
await testAuthentication();
await testPortLocking();
await testDockerSecurity();
await testSecretsRemoval();
await testLRUCache();
await testFrontendCSRF();
await testPathTraversal();
// Summary
logSection('TEST SUMMARY');
const passRate = testResults.total > 0
? ((testResults.passed / testResults.total) * 100).toFixed(1)
: 0;
log(`Total Tests: ${testResults.total}`, 'blue');
log(`Passed: ${testResults.passed}`, 'green');
log(`Failed: ${testResults.failed}`, testResults.failed > 0 ? 'red' : 'green');
log(`Warnings: ${testResults.warnings}`, 'yellow');
log(`Success Rate: ${passRate}%`, passRate >= 80 ? 'green' : 'yellow');
if (testResults.failed > 0) {
log('\nFailed Tests:', 'red');
testResults.details
.filter(t => !t.passed && !t.warning)
.forEach(t => log(`${t.name}: ${t.message}`, 'red'));
}
if (testResults.warnings > 0) {
log('\nWarnings (Manual Verification Needed):', 'yellow');
testResults.details
.filter(t => t.warning)
.forEach(t => log(`${t.name}: ${t.message}`, 'yellow'));
}
log('\n' + '═'.repeat(60), 'cyan');
if (testResults.failed === 0) {
log('\n✅ ALL AUTOMATED TESTS PASSED!', 'green');
log('Review warnings above for manual verification steps.\n', 'yellow');
} else {
log('\n⚠️ Some tests failed. Review details above.\n', 'yellow');
}
process.exit(testResults.failed > 0 ? 1 : 0);
}
// Run tests
if (require.main === module) {
runAllTests().catch(error => {
log(`\nFatal error: ${error.message}`, 'red');
console.error(error);
process.exit(1);
});
}
module.exports = { runAllTests };
@@ -0,0 +1,386 @@
#!/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 };
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env node
/**
* Refactor helper: rewrites require('./xxx') / require('../xxx') paths in
* dashcaddy-api to point to the new src/<subdir>/xxx.js locations.
*
* Algorithm:
* 1. For each require() call with a relative spec:
* 2. If the resolved file exists, leave it alone.
* 3. If the resolved file does NOT exist, the bare name of the spec
* (or the directory name 'dns-providers') might be one of the
* modules that was moved out of the repo root. In that case, rewrite
* the spec to the correct relative path to the new location.
* 4. Otherwise leave alone.
*/
const fs = require('fs');
const path = require('path');
const REPO = process.cwd();
// Map: bare module name (no extension) -> new repo-relative path (no extension)
const NEW_LOCATIONS = {
'auth-manager': 'src/managers/auth-manager',
'credential-manager': 'src/managers/credential-manager',
'license-manager': 'src/managers/license-manager',
'port-lock-manager': 'src/managers/port-lock-manager',
'state-manager': 'src/managers/state-manager',
'notification-manager': 'src/managers/notification-manager',
'resource-monitor': 'src/managers/resource-monitor',
'config-drift-detector': 'src/managers/config-drift-detector',
'auto-restart-manager': 'src/managers/auto-restart-manager',
'update-manager': 'src/managers/update-manager',
'dependency-manager': 'src/managers/dependency-manager',
'csrf-protection': 'src/security/csrf-protection',
'crypto-utils': 'src/security/crypto-utils',
'docker-security': 'src/security/docker-security',
'input-validator': 'src/security/input-validator',
'keychain-manager': 'src/security/keychain-manager',
'log-digest': 'src/security/log-digest',
'audit-logger': 'src/security/audit-logger',
'docker-maintenance': 'src/docker/docker-maintenance',
'app-templates': 'src/docker/app-templates',
'self-updater': 'src/docker/self-updater',
'dns-propagation': 'src/dns/dns-propagation',
'recipe-templates': 'src/recipes/recipe-templates',
'bundled-workflows': 'src/recipes/bundled-workflows',
'health-checker': 'src/monitoring/health-checker',
'metrics': 'src/monitoring/metrics',
'ssl-monitor': 'src/monitoring/ssl-monitor',
'backup-manager': 'src/utilities/backup-manager',
'error-handler': 'src/utilities/error-handler',
'errors': 'src/utilities/errors',
'fs-helpers': 'src/utilities/fs-helpers',
'pagination': 'src/utilities/pagination',
'url-resolver': 'src/utilities/url-resolver',
'config-schema': 'src/utilities/config-schema',
'constants': 'src/utilities/constants',
'middleware': 'src/utilities/middleware',
'startup-validator': 'src/utilities/startup-validator',
'cache-config': 'src/utilities/cache-config',
};
const SKIP_DIRS = new Set(['node_modules', '.git']);
const SKIP_FILE_PATTERNS = [/\/scripts\/refactor-requires\.js$/];
function* walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(full);
} else if (entry.name.endsWith('.js')) {
yield full;
}
}
}
function toRelativeFromFile(filePath, targetRel) {
const fromDir = path.dirname(filePath);
const targetAbs = path.resolve(REPO, targetRel);
let rel = path.relative(fromDir, targetAbs);
if (!rel.startsWith('.')) rel = './' + rel;
return rel.split(path.sep).join('/');
}
function fileExistsWithJsOrIndex(p) {
// exists if p is a file, or p is a dir with index.js
try {
if (fs.existsSync(p) && fs.statSync(p).isFile()) return true;
} catch (_) {}
try {
if (fs.existsSync(p + '.js') && fs.statSync(p + '.js').isFile()) return true;
} catch (_) {}
try {
if (
fs.existsSync(p) &&
fs.statSync(p).isDirectory() &&
fs.existsSync(path.join(p, 'index.js'))
)
return true;
} catch (_) {}
return false;
}
function refactor(filePath) {
const relFile = path.relative(REPO, filePath);
if (SKIP_FILE_PATTERNS.some((re) => re.test(relFile))) return false;
const content = fs.readFileSync(filePath, 'utf8');
let changed = false;
const requireRe = /require\(\s*(['"])([^'"]+)\1\s*\)/g;
const newContent = content.replace(requireRe, (full, quote, spec) => {
if (!spec.startsWith('.')) return full; // package require, leave alone
const fromDir = path.dirname(filePath);
const resolvedBase = path.resolve(fromDir, spec);
// If the resolved file exists, the require is correct as-is.
if (fileExistsWithJsOrIndex(resolvedBase)) {
// But — check for the special case: require to <REPO>/dns-providers/x
// which after move becomes <REPO>/src/dns/dns-providers/x — wait,
// that doesn't exist anymore. The dir was moved.
const dnsProvidersOld = path.resolve(REPO, 'dns-providers');
if (
resolvedBase === dnsProvidersOld ||
resolvedBase.startsWith(dnsProvidersOld + path.sep)
) {
const subPath = resolvedBase === dnsProvidersOld ? '' : resolvedBase.slice(dnsProvidersOld.length + 1);
const newResolved = path.resolve(REPO, 'src/dns/dns-providers', subPath);
let rel = path.relative(fromDir, newResolved);
if (!rel.startsWith('.')) rel = './' + rel;
const newSpec = rel.split(path.sep).join('/');
changed = true;
return `require(${quote}${newSpec}${quote})`;
}
return full;
}
// The file does not exist. Check if the bare name is a moved module.
const bare = path.basename(resolvedBase);
if (bare in NEW_LOCATIONS) {
const target = NEW_LOCATIONS[bare];
const newSpec = toRelativeFromFile(filePath, target);
changed = true;
return `require(${quote}${newSpec}${quote})`;
}
// Bare not in map. Check for the special case: the spec points into
// the OLD dns-providers dir (now src/dns/dns-providers). E.g. spec
// could be '../dns-providers/registry' or './dns-providers/registry'
// from somewhere else.
if (spec.includes('dns-providers')) {
const dnsProvidersOld = path.resolve(REPO, 'dns-providers');
if (
resolvedBase === dnsProvidersOld ||
resolvedBase.startsWith(dnsProvidersOld + path.sep)
) {
const subPath =
resolvedBase === dnsProvidersOld ? '' : resolvedBase.slice(dnsProvidersOld.length + 1);
const newResolved = path.resolve(REPO, 'src/dns/dns-providers', subPath);
let rel = path.relative(fromDir, newResolved);
if (!rel.startsWith('.')) rel = './' + rel;
const newSpec = rel.split(path.sep).join('/');
changed = true;
return `require(${quote}${newSpec}${quote})`;
}
}
return full;
});
if (changed) {
fs.writeFileSync(filePath, newContent);
}
return changed;
}
let count = 0;
for (const file of walk(REPO)) {
if (refactor(file)) {
count += 1;
console.log('rewrote', path.relative(REPO, file));
}
}
console.log(`\nDone: rewrote ${count} file(s).`);