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

@@ -4,7 +4,7 @@
* 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 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) {
const escapedValue = value.replace(/"/g, '""');
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' });
execFileSync('cmdkey', [`/generic:${SERVICE_NAME}:${account}`, `/user:${account}`, `/pass:${value}`], { stdio: 'ignore' });
return true;
}
async retrieveWindows(account) {
try {
const script = `
$cred = cmdkey /list:"${SERVICE_NAME}:${account}"
if ($cred -match "Password: (.+)") { $matches[1] }
`;
const result = execSync(`powershell -Command "${script.replace(/\n/g, ' ')}"`, { encoding: 'utf8' });
return result.trim() || null;
const result = execFileSync('cmdkey', [`/list:${SERVICE_NAME}:${account}`], { encoding: 'utf8' });
const match = result.match(/Password:\s*(.+)/);
return match ? match[1].trim() : null;
} catch {
return null;
}
}
async deleteWindows(account) {
execSync(`cmdkey /delete:"${SERVICE_NAME}:${account}"`, { stdio: 'ignore' });
execFileSync('cmdkey', [`/delete:${SERVICE_NAME}:${account}`], { stdio: 'ignore' });
return true;
}
// macOS Keychain implementation
// macOS Keychain implementation (uses execFileSync to prevent injection)
async storeMacOS(account, value) {
// Delete existing entry first
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 {
// Ignore if doesn't exist
}
// Add new entry
execSync(`security add-generic-password -s "${SERVICE_NAME}" -a "${account}" -w "${value}"`, { stdio: 'ignore' });
execFileSync('security', ['add-generic-password', '-s', SERVICE_NAME, '-a', account, '-w', value], { stdio: 'ignore' });
return true;
}
async retrieveMacOS(account) {
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;
} catch {
return null;
@@ -185,38 +173,26 @@ class KeychainManager {
}
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;
}
// Linux Secret Service implementation
// Linux Secret Service implementation (uses execFileSync + stdin to prevent injection)
async storeLinux(account, value) {
try {
// Try secret-tool first (libsecret)
execSync(`secret-tool store --label="${SERVICE_NAME}:${account}" service "${SERVICE_NAME}" account "${account}"`, {
execFileSync('secret-tool', ['store', `--label=${SERVICE_NAME}:${account}`, 'service', SERVICE_NAME, 'account', account], {
input: value,
stdio: ['pipe', 'ignore', 'ignore']
});
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 {
return false;
}
return false;
}
}
async retrieveLinux(account) {
try {
// Try secret-tool first
const result = execSync(`secret-tool lookup service "${SERVICE_NAME}" account "${account}"`, { encoding: 'utf8' });
const result = execFileSync('secret-tool', ['lookup', 'service', SERVICE_NAME, 'account', account], { encoding: 'utf8' });
return result.trim() || null;
} catch {
return null;
@@ -225,7 +201,7 @@ class KeychainManager {
async deleteLinux(account) {
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;
} catch {
return false;