Fix 16 HIGH/MEDIUM security bugs across API

HIGH fixes:
- TOTP disable now requires valid code verification
- TOTP secret removed from plaintext file storage
- Container ID validated before update/check-update/logs operations
- DNS server parameter restricted to configured servers (SSRF prevention)
- Backup export no longer includes encryption key
- Backup restore of sensitive files requires TOTP re-authentication

MEDIUM fixes:
- Session cookie Secure flag added
- Caddy reload errors no longer leaked to client
- saveConfig uses atomic locked updates via configStateManager
- Log file path traversal prevented via symlink resolution
- Credential cache entries now expire after 5 minutes
- _httpFetch enforces 10MB response size limit
- External URL path injection into Caddyfile blocked
- Custom volume host paths validated against allowed roots
- Error logs endpoint no longer returns stack traces
- Logo delete path traversal prevented via path.basename()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 00:15:28 -08:00
parent 6979302fb7
commit 59b6d7d360
12 changed files with 172 additions and 69 deletions

View File

@@ -230,9 +230,19 @@ function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
if (opts.body) {
options.headers['Content-Length'] = Buffer.byteLength(opts.body);
}
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const req = http.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
let size = 0;
res.on('data', chunk => {
size += chunk.length;
if (size > MAX_RESPONSE_SIZE) {
res.destroy();
reject(new Error(`Response from ${url} exceeded ${MAX_RESPONSE_SIZE} bytes`));
return;
}
data += chunk;
});
res.on('end', () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
@@ -317,12 +327,11 @@ async function readConfig() {
return readJsonFile(CONFIG_FILE, {});
}
/** Save config.json (merges with existing) */
/** Save config.json (merges with existing, atomic with locking) */
async function saveConfig(updates) {
const config = await readConfig();
Object.assign(config, updates);
await writeJsonFile(CONFIG_FILE, config);
return config;
return await configStateManager.update(config => {
return Object.assign(config, updates);
});
}
/**
@@ -676,17 +685,11 @@ async function loadTotpConfig() {
try {
if (await exists(TOTP_CONFIG_FILE)) {
const data = await fsp.readFile(TOTP_CONFIG_FILE, 'utf8');
Object.assign(totpConfig, JSON.parse(data));
const loaded = JSON.parse(data);
// Never load secret from file — it belongs only in credential-manager
delete loaded.secret;
Object.assign(totpConfig, loaded);
log.info('config', 'TOTP config loaded', { enabled: totpConfig.enabled });
// Auto-restore: if config has a backup secret but credential manager lost it, re-store it
if (totpConfig.secret && totpConfig.isSetUp) {
const existing = await credentialManager.retrieve('totp.secret');
if (!existing) {
await credentialManager.store('totp.secret', totpConfig.secret);
log.info('config', 'TOTP secret auto-restored from config backup');
}
}
}
} catch (e) {
await logError('loadTotpConfig', e);