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

@@ -1323,7 +1323,7 @@ const APP_TEMPLATES = {
"USER_GID": "1000" "USER_GID": "1000"
} }
}, },
subdomain: "git", subdomain: "gitea",
defaultPort: 3005, defaultPort: 3005,
healthCheck: "/", healthCheck: "/",
subpathSupport: 'native', subpathSupport: 'native',

View File

@@ -6,6 +6,7 @@
const keychainManager = require('./keychain-manager'); const keychainManager = require('./keychain-manager');
const cryptoUtils = require('./crypto-utils'); const cryptoUtils = require('./crypto-utils');
const lockfile = require('proper-lockfile');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@@ -15,6 +16,10 @@ class CredentialManager {
constructor() { constructor() {
this.useKeychain = keychainManager.available; this.useKeychain = keychainManager.available;
this.cache = new Map(); // In-memory cache for performance this.cache = new Map(); // In-memory cache for performance
this.lockOptions = {
retries: { retries: 10, minTimeout: 100, maxTimeout: 300 },
stale: 30000
};
console.log(`[CredentialManager] Initialized with ${this.useKeychain ? 'OS keychain' : 'encrypted file'} storage`); console.log(`[CredentialManager] Initialized with ${this.useKeychain ? 'OS keychain' : 'encrypted file'} storage`);
} }
@@ -152,11 +157,16 @@ class CredentialManager {
* @returns {Promise<boolean>} Success status * @returns {Promise<boolean>} Success status
*/ */
async rotateEncryptionKey() { async rotateEncryptionKey() {
let release;
try { try {
console.log('[CredentialManager] Starting encryption key rotation...'); console.log('[CredentialManager] Starting encryption key rotation...');
// Load all credentials with old key // Ensure file exists before locking
const credentials = await this.loadCredentialsFile(); this._ensureFileExists();
release = await lockfile.lock(CREDENTIALS_FILE, this.lockOptions);
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
const credentials = JSON.parse(data);
const keys = Object.keys(credentials); const keys = Object.keys(credentials);
if (keys.length === 0) { if (keys.length === 0) {
@@ -164,26 +174,31 @@ class CredentialManager {
return true; return true;
} }
// Generate new encryption key // Decrypt all values with the CURRENT key first
cryptoUtils.loadOrCreateKey(); // This will generate a new key const decryptedEntries = {};
// Re-encrypt all credentials
const rotated = {};
for (const key of keys) { for (const key of keys) {
const value = credentials[key].value; const value = credentials[key].value;
const metadata = credentials[key].metadata; decryptedEntries[key] = {
plaintext: cryptoUtils.isEncrypted(value) ? cryptoUtils.decrypt(value) : value,
metadata: credentials[key].metadata
};
}
// Decrypt with old key, encrypt with new key // Generate new key (this replaces the cached key and saves to disk)
const decrypted = cryptoUtils.isEncrypted(value) ? cryptoUtils.decrypt(value) : value; const { oldKey } = cryptoUtils.rotateKey();
// Re-encrypt all credentials with the new key
const rotated = {};
for (const key of keys) {
rotated[key] = { rotated[key] = {
value: cryptoUtils.encrypt(decrypted), value: cryptoUtils.encrypt(decryptedEntries[key].plaintext),
metadata, metadata: decryptedEntries[key].metadata,
rotatedAt: new Date().toISOString() rotatedAt: new Date().toISOString()
}; };
} }
// Save with new encryption // Save with new encryption
await this.saveCredentialsFile(rotated); fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(rotated, null, 2), { mode: 0o600 });
// Clear cache to force reload // Clear cache to force reload
this.cache.clear(); this.cache.clear();
@@ -193,6 +208,10 @@ class CredentialManager {
} catch (error) { } catch (error) {
console.error('[CredentialManager] Key rotation failed:', error.message); console.error('[CredentialManager] Key rotation failed:', error.message);
return false; return false;
} finally {
if (release) {
try { await release(); } catch (e) { /* lock will expire via stale timeout */ }
}
} }
} }
@@ -202,10 +221,10 @@ class CredentialManager {
*/ */
async migrateToEncrypted() { async migrateToEncrypted() {
try { try {
const credentials = await this.loadCredentialsFile();
let migrated = 0; let migrated = 0;
let skipped = 0; let skipped = 0;
await this._lockedUpdate(credentials => {
for (const [key, data] of Object.entries(credentials)) { for (const [key, data] of Object.entries(credentials)) {
if (!cryptoUtils.isEncrypted(data.value)) { if (!cryptoUtils.isEncrypted(data.value)) {
credentials[key].value = cryptoUtils.encrypt(data.value); credentials[key].value = cryptoUtils.encrypt(data.value);
@@ -215,9 +234,10 @@ class CredentialManager {
skipped++; skipped++;
} }
} }
return credentials;
});
if (migrated > 0) { if (migrated > 0) {
await this.saveCredentialsFile(credentials);
this.cache.clear(); this.cache.clear();
console.log(`[CredentialManager] Migrated ${migrated} plaintext credentials to encrypted format`); console.log(`[CredentialManager] Migrated ${migrated} plaintext credentials to encrypted format`);
} }
@@ -231,14 +251,57 @@ class CredentialManager {
// Private methods // Private methods
/**
* Ensure credentials file exists (needed before locking)
* @private
*/
_ensureFileExists() {
if (!fs.existsSync(CREDENTIALS_FILE)) {
const dir = path.dirname(CREDENTIALS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(CREDENTIALS_FILE, '{}', { mode: 0o600 });
}
}
/**
* Atomic read-modify-write with file locking
* @param {Function} updateFn - Receives current credentials object, returns updated object
* @returns {Promise<Object>} Updated credentials
* @private
*/
async _lockedUpdate(updateFn) {
this._ensureFileExists();
let release;
try {
release = await lockfile.lock(CREDENTIALS_FILE, this.lockOptions);
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
const credentials = JSON.parse(data);
const updated = await updateFn(credentials);
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2), { mode: 0o600 });
return updated;
} catch (error) {
if (error.code === 'ELOCKED') {
throw new Error('Credentials file is locked by another process. Try again.');
}
throw error;
} finally {
if (release) {
try { await release(); } catch (e) { /* lock will expire via stale timeout */ }
}
}
}
async storeInFile(key, value, metadata) { async storeInFile(key, value, metadata) {
const credentials = await this.loadCredentialsFile(); await this._lockedUpdate(credentials => {
credentials[key] = { credentials[key] = {
value: cryptoUtils.encrypt(value), value: cryptoUtils.encrypt(value),
metadata, metadata,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
await this.saveCredentialsFile(credentials); return credentials;
});
} }
async retrieveFromFile(key) { async retrieveFromFile(key) {
@@ -252,20 +315,22 @@ class CredentialManager {
} }
async deleteFromFile(key) { async deleteFromFile(key) {
const credentials = await this.loadCredentialsFile(); await this._lockedUpdate(credentials => {
delete credentials[key]; delete credentials[key];
await this.saveCredentialsFile(credentials); return credentials;
});
} }
async storeMetadata(key, metadata) { async storeMetadata(key, metadata) {
const credentials = await this.loadCredentialsFile(); await this._lockedUpdate(credentials => {
if (!credentials[key]) { if (!credentials[key]) {
credentials[key] = { metadata }; credentials[key] = { metadata };
} else { } else {
credentials[key].metadata = metadata; credentials[key].metadata = metadata;
} }
credentials[key].updatedAt = new Date().toISOString(); credentials[key].updatedAt = new Date().toISOString();
await this.saveCredentialsFile(credentials); return credentials;
});
} }
async loadCredentialsFile() { async loadCredentialsFile() {
@@ -281,22 +346,6 @@ class CredentialManager {
} }
} }
async saveCredentialsFile(credentials) {
try {
// Ensure directory exists
const dir = path.dirname(CREDENTIALS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Write with restrictive permissions
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 });
} catch (error) {
console.error('[CredentialManager] Failed to save credentials file:', error.message);
throw error;
}
}
/** /**
* Export credentials for backup (encrypted) * Export credentials for backup (encrypted)
* @returns {Promise<string>} Encrypted backup data * @returns {Promise<string>} Encrypted backup data
@@ -325,7 +374,7 @@ class CredentialManager {
throw new Error('Unsupported backup version'); throw new Error('Unsupported backup version');
} }
await this.saveCredentialsFile(backup.credentials); await this._lockedUpdate(() => backup.credentials);
this.cache.clear(); this.cache.clear();
console.log('[CredentialManager] Successfully imported backup'); console.log('[CredentialManager] Successfully imported backup');

View File

@@ -267,6 +267,51 @@ function writeEncryptedFile(filePath, credentials, sensitiveFields = ['password'
console.log(`[Crypto] Saved encrypted credentials to ${filePath}`); console.log(`[Crypto] Saved encrypted credentials to ${filePath}`);
} }
/**
* Rotate the encryption key — generates a new key and returns both old and new
* @returns {{ oldKey: Buffer, newKey: Buffer }} Old and new key pair
* @throws {Error} If new key cannot be saved to disk
*/
function rotateKey() {
const oldKey = loadOrCreateKey(); // Ensure we have the current key loaded
const newKey = generateKey();
try {
fs.writeFileSync(KEY_FILE, newKey.toString('hex'), { mode: 0o600 });
} catch (error) {
throw new Error(`Failed to save new encryption key: ${error.message}`);
}
// Only update the cached key after file write succeeds
encryptionKey = newKey;
return { oldKey, newKey };
}
/**
* Decrypt data using a specific key (for key rotation)
* @param {string} encryptedData - Encrypted string in format iv:authTag:ciphertext
* @param {Buffer} key - The key to decrypt with
* @returns {string} Decrypted plaintext
*/
function decryptWithKey(encryptedData, key) {
const parts = encryptedData.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted data format');
}
const iv = Buffer.from(parts[0], 'base64');
const authTag = Buffer.from(parts[1], 'base64');
const ciphertext = parts[2];
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// Initialize key on module load // Initialize key on module load
loadOrCreateKey(); loadOrCreateKey();
@@ -280,5 +325,7 @@ module.exports = {
readEncryptedFile, readEncryptedFile,
writeEncryptedFile, writeEncryptedFile,
loadOrCreateKey, loadOrCreateKey,
deriveKey deriveKey,
rotateKey,
decryptWithKey
}; };

View File

@@ -1,22 +1,36 @@
/** /**
* CSRF Protection Module * CSRF Protection Module
* Implements double-submit cookie pattern for stateless CSRF protection * Implements HMAC-signed double-submit cookie pattern for stateless CSRF protection.
* The cookie contains a random nonce; the header must carry the HMAC signature
* of that nonce computed with a server-side secret. An attacker who can inject
* a cookie still cannot forge the matching header without the secret.
*/ */
const crypto = require('crypto'); const crypto = require('crypto');
const cryptoUtils = require('./crypto-utils');
const CSRF_TOKEN_LENGTH = 32; const CSRF_TOKEN_LENGTH = 32;
const CSRF_COOKIE_NAME = 'dashcaddy_csrf'; const CSRF_COOKIE_NAME = 'dashcaddy_csrf';
const CSRF_HEADER_NAME = 'x-csrf-token'; const CSRF_HEADER_NAME = 'x-csrf-token';
/** /**
* Generate a cryptographically secure CSRF token * Generate a cryptographically secure CSRF nonce
* @returns {string} Base64URL-encoded random token * @returns {string} Base64URL-encoded random nonce
*/ */
function generateToken() { function generateToken() {
return crypto.randomBytes(CSRF_TOKEN_LENGTH).toString('base64url'); return crypto.randomBytes(CSRF_TOKEN_LENGTH).toString('base64url');
} }
/**
* Compute HMAC signature for a CSRF nonce using the server-side encryption key
* @param {string} nonce - The random nonce to sign
* @returns {string} Base64URL-encoded HMAC signature
*/
function signToken(nonce) {
const key = cryptoUtils.loadOrCreateKey();
return crypto.createHmac('sha256', key).update(nonce).digest('base64url');
}
/** /**
* Parse cookie header string into object * Parse cookie header string into object
* @param {string} cookieHeader - Cookie header value * @param {string} cookieHeader - Cookie header value
@@ -35,25 +49,23 @@ function parseCookie(cookieHeader) {
} }
/** /**
* Middleware to set CSRF cookie on all requests * Middleware to set CSRF cookie on all requests.
* Generates and sets a new token if none exists * Always generates a fresh nonce server-side (never trusts client-supplied values).
* The cookie holds the nonce; JavaScript must read it and send the HMAC signature
* in the x-csrf-token header. The /api/csrf-token endpoint provides the signature.
*/ */
function csrfCookieMiddleware(req, res, next) { function csrfCookieMiddleware(req, res, next) {
const cookies = parseCookie(req.headers.cookie); // Always generate a fresh server-side nonce
let csrfToken = cookies[CSRF_COOKIE_NAME]; const csrfNonce = generateToken();
// Generate new token if none exists // Store nonce + signature on request so endpoints can access them
if (!csrfToken) { req.csrfToken = signToken(csrfNonce);
csrfToken = generateToken(); req.csrfNonce = csrfNonce;
}
// Store token on request so endpoints can access it // Set cookie with the nonce (SameSite=Strict for additional protection)
req.csrfToken = csrfToken; res.cookie(CSRF_COOKIE_NAME, csrfNonce, {
httpOnly: false, // Must be readable by JavaScript for signing
// Set cookie (SameSite=Strict for additional protection) secure: true,
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false, // Must be readable by JavaScript for sending in headers
secure: false, // Set to true in production with HTTPS
sameSite: 'strict', sameSite: 'strict',
path: '/', path: '/',
maxAge: 24 * 60 * 60 * 1000 // 24 hours maxAge: 24 * 60 * 60 * 1000 // 24 hours
@@ -95,16 +107,21 @@ function csrfValidationMiddleware(req, res, next) {
return next(); return next();
} }
// Get token from cookie // Get nonce from cookie
const cookies = parseCookie(req.headers.cookie); const cookies = parseCookie(req.headers.cookie);
const cookieToken = cookies[CSRF_COOKIE_NAME]; const cookieNonce = cookies[CSRF_COOKIE_NAME];
// Get token from header (case-insensitive) // Get signed token from header (case-insensitive)
const headerToken = req.headers[CSRF_HEADER_NAME] || const headerToken = req.headers[CSRF_HEADER_NAME] ||
req.headers[CSRF_HEADER_NAME.toLowerCase()]; req.headers[CSRF_HEADER_NAME.toLowerCase()];
// Validate both tokens exist // Skip CSRF for API key-authenticated requests (API keys are not sent automatically by browsers)
if (!cookieToken) { if (req.headers['x-api-key'] || (req.headers.authorization && req.headers.authorization.startsWith('Bearer '))) {
return next();
}
// Validate both values exist
if (!cookieNonce) {
console.warn(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}`); console.warn(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}`);
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
@@ -122,22 +139,21 @@ function csrfValidationMiddleware(req, res, next) {
}); });
} }
// Validate tokens match using constant-time comparison // Validate that the header token is the correct HMAC signature of the cookie nonce
try { try {
const cookieBuffer = Buffer.from(cookieToken, 'base64url'); const expectedSig = signToken(cookieNonce);
const expectedBuffer = Buffer.from(expectedSig, 'base64url');
const headerBuffer = Buffer.from(headerToken, 'base64url'); const headerBuffer = Buffer.from(headerToken, 'base64url');
// Ensure buffers are same length if (expectedBuffer.length !== headerBuffer.length) {
if (cookieBuffer.length !== headerBuffer.length) {
throw new Error('Token length mismatch'); throw new Error('Token length mismatch');
} }
// Constant-time comparison if (!crypto.timingSafeEqual(expectedBuffer, headerBuffer)) {
if (!crypto.timingSafeEqual(cookieBuffer, headerBuffer)) {
throw new Error('Token mismatch'); throw new Error('Token mismatch');
} }
// Tokens match - request is valid // Signature valid — request is authentic
next(); next();
} catch (err) { } catch (err) {
@@ -155,6 +171,7 @@ module.exports = {
CSRF_COOKIE_NAME, CSRF_COOKIE_NAME,
CSRF_HEADER_NAME, CSRF_HEADER_NAME,
generateToken, generateToken,
signToken,
parseCookie, parseCookie,
csrfCookieMiddleware, csrfCookieMiddleware,
csrfValidationMiddleware csrfValidationMiddleware

View File

@@ -4,7 +4,7 @@
* Falls back to encrypted file storage if keychain is unavailable * Falls back to encrypted file storage if keychain is unavailable
*/ */
const { execSync } = require('child_process'); const { execSync, execFileSync } = require('child_process');
const os = require('os'); const os = require('os');
const crypto = require('crypto'); const crypto = require('crypto');
@@ -131,53 +131,41 @@ class KeychainManager {
} }
} }
// Windows Credential Manager implementation // Windows Credential Manager implementation (uses execFileSync to prevent injection)
async storeWindows(account, value) { async storeWindows(account, value) {
const escapedValue = value.replace(/"/g, '""'); execFileSync('cmdkey', [`/generic:${SERVICE_NAME}:${account}`, `/user:${account}`, `/pass:${value}`], { stdio: 'ignore' });
const script = `
$password = ConvertTo-SecureString -String "${escapedValue}" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential("${account}", $password)
cmdkey /generic:"${SERVICE_NAME}:${account}" /user:"${account}" /pass:"${escapedValue}"
`;
execSync(`powershell -Command "${script.replace(/\n/g, ' ')}"`, { stdio: 'ignore' });
return true; return true;
} }
async retrieveWindows(account) { async retrieveWindows(account) {
try { try {
const script = ` const result = execFileSync('cmdkey', [`/list:${SERVICE_NAME}:${account}`], { encoding: 'utf8' });
$cred = cmdkey /list:"${SERVICE_NAME}:${account}" const match = result.match(/Password:\s*(.+)/);
if ($cred -match "Password: (.+)") { $matches[1] } return match ? match[1].trim() : null;
`;
const result = execSync(`powershell -Command "${script.replace(/\n/g, ' ')}"`, { encoding: 'utf8' });
return result.trim() || null;
} catch { } catch {
return null; return null;
} }
} }
async deleteWindows(account) { async deleteWindows(account) {
execSync(`cmdkey /delete:"${SERVICE_NAME}:${account}"`, { stdio: 'ignore' }); execFileSync('cmdkey', [`/delete:${SERVICE_NAME}:${account}`], { stdio: 'ignore' });
return true; return true;
} }
// macOS Keychain implementation // macOS Keychain implementation (uses execFileSync to prevent injection)
async storeMacOS(account, value) { async storeMacOS(account, value) {
// Delete existing entry first
try { try {
execSync(`security delete-generic-password -s "${SERVICE_NAME}" -a "${account}"`, { stdio: 'ignore' }); execFileSync('security', ['delete-generic-password', '-s', SERVICE_NAME, '-a', account], { stdio: 'ignore' });
} catch { } catch {
// Ignore if doesn't exist // Ignore if doesn't exist
} }
execFileSync('security', ['add-generic-password', '-s', SERVICE_NAME, '-a', account, '-w', value], { stdio: 'ignore' });
// Add new entry
execSync(`security add-generic-password -s "${SERVICE_NAME}" -a "${account}" -w "${value}"`, { stdio: 'ignore' });
return true; return true;
} }
async retrieveMacOS(account) { async retrieveMacOS(account) {
try { try {
const result = execSync(`security find-generic-password -s "${SERVICE_NAME}" -a "${account}" -w`, { encoding: 'utf8' }); const result = execFileSync('security', ['find-generic-password', '-s', SERVICE_NAME, '-a', account, '-w'], { encoding: 'utf8' });
return result.trim() || null; return result.trim() || null;
} catch { } catch {
return null; return null;
@@ -185,38 +173,26 @@ class KeychainManager {
} }
async deleteMacOS(account) { async deleteMacOS(account) {
execSync(`security delete-generic-password -s "${SERVICE_NAME}" -a "${account}"`, { stdio: 'ignore' }); execFileSync('security', ['delete-generic-password', '-s', SERVICE_NAME, '-a', account], { stdio: 'ignore' });
return true; return true;
} }
// Linux Secret Service implementation // Linux Secret Service implementation (uses execFileSync + stdin to prevent injection)
async storeLinux(account, value) { async storeLinux(account, value) {
try { try {
// Try secret-tool first (libsecret) execFileSync('secret-tool', ['store', `--label=${SERVICE_NAME}:${account}`, 'service', SERVICE_NAME, 'account', account], {
execSync(`secret-tool store --label="${SERVICE_NAME}:${account}" service "${SERVICE_NAME}" account "${account}"`, {
input: value, input: value,
stdio: ['pipe', 'ignore', 'ignore'] stdio: ['pipe', 'ignore', 'ignore']
}); });
return true; return true;
} catch {
// Fallback to gnome-keyring if available
try {
const script = `
echo "${value}" | gnome-keyring-daemon --unlock
echo "${value}" | gnome-keyring --set-password "${SERVICE_NAME}:${account}"
`;
execSync(script, { stdio: 'ignore' });
return true;
} catch { } catch {
return false; return false;
} }
} }
}
async retrieveLinux(account) { async retrieveLinux(account) {
try { try {
// Try secret-tool first const result = execFileSync('secret-tool', ['lookup', 'service', SERVICE_NAME, 'account', account], { encoding: 'utf8' });
const result = execSync(`secret-tool lookup service "${SERVICE_NAME}" account "${account}"`, { encoding: 'utf8' });
return result.trim() || null; return result.trim() || null;
} catch { } catch {
return null; return null;
@@ -225,7 +201,7 @@ class KeychainManager {
async deleteLinux(account) { async deleteLinux(account) {
try { try {
execSync(`secret-tool clear service "${SERVICE_NAME}" account "${account}"`, { stdio: 'ignore' }); execFileSync('secret-tool', ['clear', 'service', SERVICE_NAME, 'account', account], { stdio: 'ignore' });
return true; return true;
} catch { } catch {
return false; return false;

View File

@@ -1,6 +1,7 @@
const fs = require('fs'); const fs = require('fs');
const fsp = require('fs').promises; const fsp = require('fs').promises;
const path = require('path'); const path = require('path');
const crypto = require('crypto');
const { REGEX, DOCKER } = require('../../constants'); const { REGEX, DOCKER } = require('../../constants');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
const platformPaths = require('../../platform-paths'); const platformPaths = require('../../platform-paths');
@@ -70,7 +71,8 @@ module.exports = function(ctx) {
'{{SUBDOMAIN}}': config.subdomain, '{{SUBDOMAIN}}': config.subdomain,
'{{PORT}}': config.port || template.defaultPort, '{{PORT}}': config.port || template.defaultPort,
'{{MEDIA_PATH}}': mediaPaths[0] || '/media', '{{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) { function replaceInObject(obj) {

View File

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

View File

@@ -155,12 +155,8 @@ module.exports = function(ctx) {
return ctx.errorResponse(res, 409, `[DC-302] Site block for "${domain}" already exists in Caddyfile`); return ctx.errorResponse(res, 409, `[DC-302] Site block for "${domain}" already exists in Caddyfile`);
} }
let newSiteBlock; // Always generate structured config — never allow raw Caddy config injection
if (config) { const newSiteBlock = `\n${domain} {\n reverse_proxy ${upstream}\n tls internal\n}\n`;
newSiteBlock = `\n${config}\n`;
} else {
newSiteBlock = `\n${domain} {\n reverse_proxy ${upstream}\n tls internal\n}\n`;
}
const result = await ctx.caddy.modify(c => c + newSiteBlock); const result = await ctx.caddy.modify(c => c + newSiteBlock);
if (!result.success) { if (!result.success) {