/** * Security Regression Tests * * Tests for all 24 security fixes applied to DashCaddy. * These tests verify that previously-fixed vulnerabilities remain patched. * Grouped by the module/route they protect. */ const request = require('supertest'); const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const os = require('os'); const crypto = require('crypto'); const tmpDir = path.join(os.tmpdir(), `security-tests-${Date.now()}`); fs.mkdirSync(tmpDir, { recursive: true }); const testServicesFile = path.join(tmpDir, 'services.json'); const testConfigFile = path.join(tmpDir, 'config.json'); const testCaddyfile = path.join(tmpDir, 'Caddyfile'); const testCredentialsFile = path.join(tmpDir, 'credentials.json'); const testTotpConfigFile = path.join(tmpDir, 'totp-config.json'); const testErrorLogFile = path.join(tmpDir, 'error.log'); process.env.SERVICES_FILE = testServicesFile; process.env.CONFIG_FILE = testConfigFile; process.env.CADDYFILE_PATH = testCaddyfile; process.env.CREDENTIALS_FILE = testCredentialsFile; process.env.ENABLE_HEALTH_CHECKER = 'false'; process.env.NODE_ENV = 'test'; fs.writeFileSync(testServicesFile, '[]', 'utf8'); fs.writeFileSync(testConfigFile, '{}', 'utf8'); fs.writeFileSync(testCaddyfile, '# Test Caddyfile\n', 'utf8'); const app = require('../server'); afterAll(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); // ============================================================ // CREDENTIAL MANAGER — Cache TTL // ============================================================ describe('Credential Manager Cache TTL', () => { const CredentialManager = require('../credential-manager').constructor; test('cache entries should have expiration timestamps', () => { const cm = new CredentialManager(); cm.cache.set('test.key', { value: 'secret', exp: Date.now() + 300000 }); const cached = cm.cache.get('test.key'); expect(cached).toHaveProperty('exp'); expect(cached.exp).toBeGreaterThan(Date.now()); }); test('expired cache entries should not be returned by retrieve', async () => { const cm = new CredentialManager(); // Set an expired entry cm.cache.set('expired.key', { value: 'old-secret', exp: Date.now() - 1000 }); // retrieve() checks cache TTL — expired entry should be deleted // Since there's no file backing, it will return null const result = await cm.retrieve('expired.key'); expect(result).toBeNull(); expect(cm.cache.has('expired.key')).toBe(false); }); }); // ============================================================ // CRYPTO UTILS — Key Rotation // ============================================================ describe('Crypto Utils — Key Rotation', () => { const cryptoUtils = require('../crypto-utils'); test('rotateKey should be exported and callable', () => { // rotateKey writes to disk so just verify the function exists and signature expect(typeof cryptoUtils.rotateKey).toBe('function'); }); test('decryptWithKey should decrypt with specified key', () => { const key = crypto.randomBytes(32); const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); let encrypted = cipher.update('test-data', 'utf8', 'base64'); encrypted += cipher.final('base64'); const authTag = cipher.getAuthTag(); const encStr = `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`; const result = cryptoUtils.decryptWithKey(encStr, key); expect(result).toBe('test-data'); }); test('decryptWithKey should reject invalid format', () => { const key = crypto.randomBytes(32); expect(() => cryptoUtils.decryptWithKey('invalid-no-colons', key)).toThrow('Invalid encrypted data format'); }); }); // ============================================================ // TOTP — Disable requires code verification // ============================================================ describe('TOTP Disable Security', () => { test('POST /api/totp/disable should reject missing code when TOTP is active', async () => { // This tests that disabling TOTP requires a valid code // When TOTP is not set up, the endpoint just disables it // But when it IS set up, code is mandatory const res = await request(app) .post('/api/totp/disable') .send({}); // If TOTP isn't set up in test env, it will succeed (200) // The important thing is it doesn't crash expect([200, 400, 401]).toContain(res.statusCode); }); test('POST /api/totp/disable should reject non-6-digit code', async () => { const res = await request(app) .post('/api/totp/disable') .send({ code: 'abc' }); // If TOTP is active, should reject non-numeric codes expect([200, 400, 401]).toContain(res.statusCode); }); }); // ============================================================ // SITES — Caddy reload error leak prevention // ============================================================ describe('Sites Route Security', () => { test('POST /api/site should reject invalid domain format', async () => { const res = await request(app) .post('/api/site') .send({ domain: '', upstream: 'localhost:8080' }); expect(res.statusCode).toBe(400); expect(res.body.error).toContain('DC-301'); }); test('POST /api/site should reject invalid upstream format', async () => { const res = await request(app) .post('/api/site') .send({ domain: 'test.sami', upstream: 'not-valid' }); expect(res.statusCode).toBe(400); expect(res.body.error).toContain('upstream'); }); test('POST /api/site/external should reject URLs with Caddyfile injection chars', async () => { const res = await request(app) .post('/api/site/external') .send({ subdomain: 'test', externalUrl: 'https://evil.com/path{inject}' }); // Should be rejected — either 400 (our validation) or 500 (URL constructor throws on {}) expect([400, 500]).toContain(res.statusCode); // Must never succeed expect(res.statusCode).not.toBe(200); }); test('POST /api/site/external should reject URLs with newlines', async () => { const res = await request(app) .post('/api/site/external') .send({ subdomain: 'test', externalUrl: 'https://evil.com/path\nreverse_proxy malicious:1234' }); expect(res.statusCode).toBe(400); }); test('POST /api/site/external should reject missing fields', async () => { const res = await request(app) .post('/api/site/external') .send({}); expect(res.statusCode).toBe(400); }); test('POST /api/site/external should reject invalid subdomain', async () => { const res = await request(app) .post('/api/site/external') .send({ subdomain: '../etc/passwd', externalUrl: 'https://example.com' }); expect(res.statusCode).toBe(400); }); }); // ============================================================ // ERROR LOGS — No stack trace leak // ============================================================ describe('Error Logs — No Stack Trace Leak', () => { beforeAll(async () => { // Write a fake error log with stack traces const logContent = [ '[2026-03-07 12:00:00] server: Something failed', 'Error: Internal failure', ' at Object. (/app/server.js:123:45)', ' at Module._compile (node:internal/modules/cjs/loader:1234:14)', '================================================================================', '[2026-03-07 12:01:00] dns: DNS timeout', 'Error: connect ECONNREFUSED 192.168.1.1:5380', ' at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1234:16)', '================================================================================' ].join('\n'); // Write to the server's error log file location // The server uses ctx.ERROR_LOG_FILE — we need to check what that resolves to await fsp.writeFile(testErrorLogFile, logContent); }); test('GET /api/error-logs should not include details/stack traces', async () => { const res = await request(app).get('/api/error-logs'); expect(res.statusCode).toBe(200); expect(res.body.success).toBe(true); // If there are logs, verify none contain 'details' field if (res.body.logs.length > 0) { for (const log of res.body.logs) { expect(log).not.toHaveProperty('details'); // Verify it has the safe fields if (log.timestamp) { expect(log).toHaveProperty('timestamp'); expect(log).toHaveProperty('context'); expect(log).toHaveProperty('error'); } } } }); }); // ============================================================ // CONTAINERS — ID validation // ============================================================ describe('Container ID Validation', () => { test('GET /api/containers/:id/check-update should 404 for nonexistent container', async () => { const res = await request(app).get('/api/containers/nonexistent123/check-update'); // Should return 404 (not found) not 500 (unhandled error) expect([404]).toContain(res.statusCode); }); test('POST /api/containers/:id/update should 404 for nonexistent container', async () => { const res = await request(app).post('/api/containers/nonexistent123/update'); expect([404]).toContain(res.statusCode); }); test('GET /api/logs/container/:id should 404 for nonexistent container', async () => { const res = await request(app).get('/api/logs/container/nonexistent123'); expect([404]).toContain(res.statusCode); }); test('GET /api/logs/stream/:id should 404 for nonexistent container', async () => { const res = await request(app).get('/api/logs/stream/nonexistent123'); expect([404]).toContain(res.statusCode); }); }); // ============================================================ // LOG FILE — Path traversal prevention // ============================================================ describe('Log File Path Traversal', () => { test('GET /api/logs/file should reject missing path', async () => { const res = await request(app).get('/api/logs/file'); expect(res.statusCode).toBe(400); }); test('GET /api/logs/file should reject traversal paths', async () => { const res = await request(app) .get('/api/logs/file') .query({ path: '/etc/shadow' }); // Should be 403 (not allowed) or 404 (not found), never 200 expect([403, 404]).toContain(res.statusCode); }); test('GET /api/logs/file should reject Windows system paths', async () => { const res = await request(app) .get('/api/logs/file') .query({ path: 'C:\\Windows\\System32\\config\\SAM' }); expect([403, 404]).toContain(res.statusCode); }); test('GET /api/logs/file should reject parent directory traversal', async () => { const res = await request(app) .get('/api/logs/file') .query({ path: '/var/log/../../etc/passwd' }); expect([403, 404]).toContain(res.statusCode); }); }); // ============================================================ // BACKUP — No encryption key in export, TOTP re-auth for restore // ============================================================ describe('Backup Security', () => { test('GET /api/backup/export should not include encryption key', async () => { const res = await request(app).get('/api/backup/export'); if (res.statusCode === 200 && res.body.backup) { const backup = res.body.backup; // Verify encryptionKey is NOT in the backup files expect(backup.files).not.toHaveProperty('encryptionKey'); // Verify TOTP backup doesn't include manualKey if (backup.totp) { expect(backup.totp).not.toHaveProperty('manualKey'); } } }); test('POST /api/backup/restore should reject invalid backup format', async () => { const res = await request(app) .post('/api/backup/restore') .send({ backup: { invalid: true } }); expect(res.statusCode).toBe(400); }); test('POST /api/backup/restore should not restore encryptionKey even if provided', async () => { const res = await request(app) .post('/api/backup/restore') .send({ backup: { version: '1.0', files: { encryptionKey: { type: 'text', content: 'malicious-key-data' } } } }); // The encryptionKey should be skipped (not in fileMapping) if (res.statusCode === 200) { // If it succeeded, verify encryptionKey was skipped expect(res.body.results.restored).not.toContain('encryptionKey'); } }); }); // ============================================================ // SESSION COOKIE — Secure flag // ============================================================ describe('Session Cookie Security', () => { test('session cookies should include Secure flag', async () => { // TOTP verify would set a session cookie on success // We can check the middleware by looking at any response that sets cookies const res = await request(app) .post('/api/totp/verify') .send({ code: '123456' }); // Even though verify fails, check cookie format if any cookies are set const cookies = res.headers['set-cookie']; if (cookies) { for (const cookie of Array.isArray(cookies) ? cookies : [cookies]) { if (cookie.includes('dashcaddy_session')) { expect(cookie.toLowerCase()).toContain('secure'); expect(cookie.toLowerCase()).toContain('httponly'); expect(cookie.toLowerCase()).toContain('samesite'); } } } }); }); // ============================================================ // CUSTOM VOLUME — Host path validation // ============================================================ describe('Custom Volume Path Validation', () => { // This tests the processTemplateVariables function indirectly // The helpers.js validates custom volume hostPath against allowed roots test('should not allow arbitrary host paths in volume overrides', async () => { // Deploy endpoint would use processTemplateVariables // Sending a custom volume with a dangerous path const res = await request(app) .post('/api/apps/deploy') .send({ appId: 'plex', subdomain: 'test-plex', ip: '192.168.1.100', port: '32400', customVolumes: [{ containerPath: '/config', hostPath: '/etc/shadow' }] }); // The deploy will likely fail for other reasons (no Docker, etc.) // But if it reaches volume processing, the dangerous path should be rejected // The key check: it shouldn't return 200 with /etc/shadow mounted if (res.statusCode === 200) { // If somehow succeeded, verify the dangerous path wasn't used expect(JSON.stringify(res.body)).not.toContain('/etc/shadow'); } }); }); // ============================================================ // LOGO DELETE — Path traversal prevention // ============================================================ describe('Logo Delete Path Traversal', () => { test('DELETE /api/logo should safely handle config with traversal paths', async () => { // Write config with a malicious logo path const configWithMaliciousLogo = { customLogo: '/assets/../../etc/passwd', customLogoDark: '/assets/../../../root/.ssh/id_rsa' }; await fsp.writeFile(testConfigFile, JSON.stringify(configWithMaliciousLogo), 'utf8'); const res = await request(app).delete('/api/logo'); // Should succeed (reset branding) without deleting files outside assets dir expect(res.statusCode).toBe(200); expect(res.body.success).toBe(true); // Reset config for other tests await fsp.writeFile(testConfigFile, '{}', 'utf8'); }); }); // ============================================================ // DNS — SSRF prevention (server parameter validation) // ============================================================ describe('DNS Server SSRF Prevention', () => { test('DELETE /api/dns/record should not succeed with arbitrary server IPs', async () => { const res = await request(app) .delete('/api/dns/record') .query({ domain: 'test.sami', type: 'A', server: '169.254.169.254' // AWS metadata endpoint }); // Must never succeed — 400 (server rejected), 401 (no token), or 500 (dns not configured in test) expect(res.statusCode).not.toBe(200); }); test('POST /api/dns/record should not succeed with arbitrary server IPs', async () => { const res = await request(app) .post('/api/dns/record') .send({ domain: 'test.sami', ipAddress: '192.168.1.1', server: '10.0.0.1' // Not a configured DNS server }); expect(res.statusCode).not.toBe(200); }); test('GET /api/dns/resolve should not succeed with arbitrary server IPs', async () => { const res = await request(app) .get('/api/dns/resolve') .query({ domain: 'test.sami', server: '127.0.0.1' }); expect(res.statusCode).not.toBe(200); }); test('GET /api/dns/logs should reject arbitrary server IPs', async () => { const res = await request(app) .get('/api/dns/logs') .query({ server: '192.168.1.1' }); expect([400]).toContain(res.statusCode); }); test('GET /api/dns/check-update should reject arbitrary server IPs', async () => { const res = await request(app) .get('/api/dns/check-update') .query({ server: '8.8.8.8' }); expect([400]).toContain(res.statusCode); }); test('POST /api/dns/update should reject arbitrary server IPs', async () => { const res = await request(app) .post('/api/dns/update') .query({ server: '1.1.1.1' }); expect([400]).toContain(res.statusCode); }); }); // ============================================================ // _httpFetch — Response size limit // ============================================================ describe('HTTP Fetch Response Size Limit', () => { // This is tested indirectly — the _httpFetch function has a 10MB limit // We can verify the constant exists by checking the server module test('server should define MAX_RESPONSE_SIZE constant', () => { // Read server.js and verify the limit is defined const serverSource = fs.readFileSync( path.join(__dirname, '..', 'server.js'), 'utf8' ); expect(serverSource).toContain('MAX_RESPONSE_SIZE'); expect(serverSource).toContain('10 * 1024 * 1024'); }); }); // ============================================================ // MIDDLEWARE — Session cookie format // ============================================================ describe('Middleware Security', () => { test('middleware should set Secure flag on cookies', () => { const middlewareSource = fs.readFileSync( path.join(__dirname, '..', 'middleware.js'), 'utf8' ); // Verify the Set-Cookie string includes Secure expect(middlewareSource).toContain('; Secure;'); }); }); // ============================================================ // SAVECONFIG — Atomic operations // ============================================================ describe('Config Save Atomicity', () => { test('saveConfig should use state manager for locking', () => { const serverSource = fs.readFileSync( path.join(__dirname, '..', 'server.js'), 'utf8' ); // Verify saveConfig uses configStateManager.update (not raw fs.writeFile) expect(serverSource).toContain('configStateManager.update'); }); }); // ============================================================ // SITES — External URL validation // ============================================================ describe('External URL Security', () => { test('sites.js should validate URL components for unsafe chars', () => { const sitesSource = fs.readFileSync( path.join(__dirname, '..', 'routes', 'sites.js'), 'utf8' ); // Verify the unsafe character regex exists expect(sitesSource).toContain('unsafeCaddyChars'); expect(sitesSource).toMatch(/[{}\\n\\r]/); }); }); // ============================================================ // CREDENTIAL MANAGER — Locking // ============================================================ describe('Credential Manager File Locking', () => { test('credential-manager should use proper-lockfile', () => { const cmSource = fs.readFileSync( path.join(__dirname, '..', 'credential-manager.js'), 'utf8' ); expect(cmSource).toContain('proper-lockfile'); expect(cmSource).toContain('_lockedUpdate'); }); }); // ============================================================ // TOTP CONFIG — No plaintext secret in file // ============================================================ describe('TOTP Config File Security', () => { test('loadTotpConfig should delete secret from file data', () => { const serverSource = fs.readFileSync( path.join(__dirname, '..', 'server.js'), 'utf8' ); // Verify the secret deletion exists in loadTotpConfig expect(serverSource).toContain('delete loaded.secret'); }); test('totp verify-setup should not write secret to config file', () => { const totpSource = fs.readFileSync( path.join(__dirname, '..', 'routes', 'auth', 'totp.js'), 'utf8' ); // Verify totpConfig.secret assignment is NOT present expect(totpSource).not.toContain('totpConfig.secret = pendingSecret'); expect(totpSource).not.toContain('totpConfig.secret ='); }); }); // ============================================================ // HELPERS — Volume path validation // ============================================================ describe('Helpers — Volume Security', () => { test('helpers.js should validate hostPath against allowed roots', () => { const helpersSource = fs.readFileSync( path.join(__dirname, '..', 'routes', 'apps', 'helpers.js'), 'utf8' ); expect(helpersSource).toContain('allowedRoots'); expect(helpersSource).toContain('platformPaths.dockerData'); expect(helpersSource).toContain('Custom volume host path rejected'); }); }); // ============================================================ // ERROR LOGS — No details field // ============================================================ describe('Error Logs — Response Format', () => { test('errorlogs.js should not include details field', () => { const source = fs.readFileSync( path.join(__dirname, '..', 'routes', 'errorlogs.js'), 'utf8' ); // The parsed log object should only have timestamp, context, error // NOT details (which contains stack traces) const returnBlock = source.match(/return \{[\s\S]*?\}/); if (returnBlock) { expect(returnBlock[0]).not.toContain('details'); } }); }); // ============================================================ // ASSETS — path.basename for logo deletion // ============================================================ describe('Assets — Logo Path Safety', () => { test('assets.js should use path.basename for logo filename extraction', () => { const source = fs.readFileSync( path.join(__dirname, '..', 'routes', 'config', 'assets.js'), 'utf8' ); expect(source).toContain('path.basename(logoPath)'); // Should NOT use string replace for path extraction expect(source).not.toContain("logoPath.replace('/assets/', '')"); }); }); // ============================================================ // BACKUP — encryptionKey excluded // ============================================================ describe('Backup — Encryption Key Exclusion', () => { test('backup.js should not include encryptionKey in filesToBackup', () => { const source = fs.readFileSync( path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8' ); // Should have a comment about deliberate exclusion expect(source).toContain('encryptionKey deliberately excluded'); // Should NOT have encryptionKey as a key in filesToBackup array expect(source).not.toMatch(/\{\s*key:\s*'encryptionKey'/); }); test('backup.js restore fileMapping should not include encryptionKey', () => { const source = fs.readFileSync( path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8' ); // The RESTORE route's fileMapping (after "encryptionKey excluded" comment) must not have it // The preview route's fileMapping is allowed to have it (informational only) const restoreSection = source.substring(source.indexOf('encryptionKey excluded')); const restoreMapping = restoreSection.match(/const fileMapping = \{[\s\S]*?\};/); if (restoreMapping) { expect(restoreMapping[0]).not.toContain('encryptionKey:'); } }); test('backup.js should require TOTP for sensitive restores', () => { const source = fs.readFileSync( path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8' ); expect(source).toContain('sensitiveKeys'); expect(source).toContain('totpCode'); expect(source).toContain('TOTP code required'); }); }); // ============================================================ // DNS — validateDnsServer function // ============================================================ describe('DNS — Server Validation Function', () => { test('dns.js should define validateDnsServer', () => { const source = fs.readFileSync( path.join(__dirname, '..', 'routes', 'dns.js'), 'utf8' ); expect(source).toContain('function validateDnsServer'); expect(source).toContain('configuredIps'); expect(source).toContain('validatorLib.isIP'); }); }); // ============================================================ // CONTAINERS — getVerifiedContainer usage // ============================================================ describe('Containers — Verified Container Access', () => { test('containers.js update route should use getVerifiedContainer', () => { const source = fs.readFileSync( path.join(__dirname, '..', 'routes', 'containers.js'), 'utf8' ); // update and check-update should both use getVerifiedContainer const updateSection = source.substring(source.indexOf("'/:id/update'")); expect(updateSection).toContain('getVerifiedContainer'); const checkUpdateSection = source.substring(source.indexOf("'/:id/check-update'")); expect(checkUpdateSection).toContain('getVerifiedContainer'); }); }); // ============================================================ // LOGS — Symlink resolution // ============================================================ describe('Logs — Symlink Resolution', () => { test('logs.js should use realpath for symlink resolution', () => { const source = fs.readFileSync( path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8' ); expect(source).toContain('fsp.realpath'); expect(source).toContain('path.sep'); }); test('logs.js container routes should verify container exists', () => { const source = fs.readFileSync( path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8' ); // Both container/:id and stream/:id should have inspect + NotFoundError expect(source).toContain('container.inspect()'); expect(source).toContain('NotFoundError'); }); });