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:
@@ -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',
|
||||||
|
|||||||
@@ -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,7 +16,11 @@ 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,47 +157,61 @@ 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) {
|
||||||
console.log('[CredentialManager] No credentials to rotate');
|
console.log('[CredentialManager] No credentials to rotate');
|
||||||
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,
|
||||||
// Decrypt with old key, encrypt with new key
|
metadata: credentials[key].metadata
|
||||||
const decrypted = cryptoUtils.isEncrypted(value) ? cryptoUtils.decrypt(value) : value;
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new key (this replaces the cached key and saves to disk)
|
||||||
|
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();
|
||||||
|
|
||||||
console.log(`[CredentialManager] Successfully rotated ${keys.length} credentials`);
|
console.log(`[CredentialManager] Successfully rotated ${keys.length} credentials`);
|
||||||
return true;
|
return true;
|
||||||
} 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,22 +221,23 @@ 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;
|
||||||
|
|
||||||
for (const [key, data] of Object.entries(credentials)) {
|
await this._lockedUpdate(credentials => {
|
||||||
if (!cryptoUtils.isEncrypted(data.value)) {
|
for (const [key, data] of Object.entries(credentials)) {
|
||||||
credentials[key].value = cryptoUtils.encrypt(data.value);
|
if (!cryptoUtils.isEncrypted(data.value)) {
|
||||||
credentials[key].migratedAt = new Date().toISOString();
|
credentials[key].value = cryptoUtils.encrypt(data.value);
|
||||||
migrated++;
|
credentials[key].migratedAt = new Date().toISOString();
|
||||||
} else {
|
migrated++;
|
||||||
skipped++;
|
} else {
|
||||||
|
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) {
|
||||||
@@ -246,26 +309,28 @@ class CredentialManager {
|
|||||||
const data = credentials[key];
|
const data = credentials[key];
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
return cryptoUtils.isEncrypted(data.value)
|
return cryptoUtils.isEncrypted(data.value)
|
||||||
? cryptoUtils.decrypt(data.value)
|
? cryptoUtils.decrypt(data.value)
|
||||||
: data.value;
|
: data.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -320,14 +369,14 @@ class CredentialManager {
|
|||||||
try {
|
try {
|
||||||
const decrypted = cryptoUtils.decrypt(encryptedBackup);
|
const decrypted = cryptoUtils.decrypt(encryptedBackup);
|
||||||
const backup = JSON.parse(decrypted);
|
const backup = JSON.parse(decrypted);
|
||||||
|
|
||||||
if (backup.version !== '1.0') {
|
if (backup.version !== '1.0') {
|
||||||
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');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
} catch {
|
||||||
// Fallback to gnome-keyring if available
|
return false;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user