Fix 7 critical security bugs and 1 high-severity data loss bug

- CSRF: HMAC-signed double-submit cookie (server-bound, not raw compare)
- Keychain: execFileSync with arg arrays to prevent command injection
- Caddy config: always use structured generation, never accept raw config
- Templates: replace {{GENERATED_SECRET}} with crypto.randomBytes
- Caddyfile removal: move regex inside ctx.caddy.modify() to fix TOCTOU race
- Credentials: proper-lockfile for all file operations, fix key rotation
  to decrypt with old key before generating new key
- Service removal: filter by ID only, not AND with appTemplate

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 23:08:30 -08:00
parent 3a6d2ce93d
commit 6979302fb7
8 changed files with 242 additions and 159 deletions

View File

@@ -1,6 +1,7 @@
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const { REGEX, DOCKER } = require('../../constants');
const { exists } = require('../../fs-helpers');
const platformPaths = require('../../platform-paths');
@@ -70,7 +71,8 @@ module.exports = function(ctx) {
'{{SUBDOMAIN}}': config.subdomain,
'{{PORT}}': config.port || template.defaultPort,
'{{MEDIA_PATH}}': mediaPaths[0] || '/media',
'{{TIMEZONE}}': ctx.siteConfig.timezone || 'UTC'
'{{TIMEZONE}}': ctx.siteConfig.timezone || 'UTC',
'{{GENERATED_SECRET}}': crypto.randomBytes(32).toString('hex')
};
function replaceInObject(obj) {

View File

@@ -68,18 +68,14 @@ module.exports = function(ctx, helpers) {
} else {
// Subdomain mode: remove standalone domain block
const domain = ctx.buildDomain(subdomain);
let content = await ctx.caddy.read();
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g');
const originalLength = content.length;
content = content.replace(siteBlockRegex, '\n');
if (content.length !== originalLength) {
content = content.replace(/\n{3,}/g, '\n\n');
const caddyResult = await ctx.caddy.modify(() => content);
results.caddy = caddyResult.success ? 'removed' : 'removed (reload failed)';
} else {
results.caddy = 'not found';
}
const caddyResult = await ctx.caddy.modify(currentContent => {
const replaced = currentContent.replace(siteBlockRegex, '\n');
if (replaced.length === currentContent.length) return null;
return replaced.replace(/\n{3,}/g, '\n\n');
});
results.caddy = caddyResult.success ? 'removed' : (caddyResult.rolledBack ? 'removed (reload failed)' : 'not found');
}
ctx.log.info('caddy', 'Caddy config removal', { result: results.caddy });
} catch (error) {
@@ -94,7 +90,7 @@ module.exports = function(ctx, helpers) {
let removed = false;
await ctx.servicesStateManager.update(services => {
const initialLength = services.length;
const filtered = services.filter(s => s.id !== subdomain && s.appTemplate !== appId);
const filtered = services.filter(s => s.id !== subdomain);
removed = filtered.length !== initialLength;
return filtered;
});