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

@@ -32,7 +32,16 @@ module.exports = function(ctx) {
const timestamps = req.query.timestamps !== 'false';
const container = ctx.docker.client.getContainer(containerId);
const info = await container.inspect();
let info;
try {
info = await container.inspect();
} catch (err) {
if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) {
const { NotFoundError } = require('../errors');
throw new NotFoundError(`Container ${containerId}`);
}
throw err;
}
const containerName = info.Name.replace(/^\//, '');
const logs = await container.logs({
@@ -74,6 +83,15 @@ module.exports = function(ctx) {
router.get('/logs/stream/:id', ctx.asyncHandler(async (req, res) => {
const containerId = req.params.id;
const container = ctx.docker.client.getContainer(containerId);
try {
await container.inspect();
} catch (err) {
if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) {
const { NotFoundError } = require('../errors');
throw new NotFoundError(`Container ${containerId}`);
}
throw err;
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
@@ -132,20 +150,32 @@ module.exports = function(ctx) {
const allowedPaths = platformPaths.allowedLogPaths;
const normalizedPath = path.normalize(logPath);
const isAllowed = allowedPaths.some(allowed =>
normalizedPath.startsWith(path.normalize(allowed))
);
// Resolve symlinks to prevent symlink-based traversal
let resolvedPath;
try {
resolvedPath = await fsp.realpath(normalizedPath);
} catch {
const { NotFoundError } = require('../errors');
throw new NotFoundError('Log file');
}
// Check path against allowed roots with separator boundary
const isAllowed = allowedPaths.some(allowed => {
const normalizedAllowed = path.normalize(allowed);
return resolvedPath === normalizedAllowed || resolvedPath.startsWith(normalizedAllowed + path.sep);
});
if (!isAllowed) {
return ctx.errorResponse(res, 403, 'Access to this log path is not allowed');
}
if (!await exists(normalizedPath)) {
if (!await exists(resolvedPath)) {
const { NotFoundError } = require('../errors');
throw new NotFoundError('Log file');
}
const fileContent = await fsp.readFile(normalizedPath, 'utf8');
const fileContent = await fsp.readFile(resolvedPath, 'utf8');
const lines = fileContent.split('\n').filter(line => line.trim());
const tailLines = lines.slice(-tail);