fix: Security hardening, wizard bug fix, and UI redesign
ci/woodpecker/push/woodpecker Pipeline was successful

- Fix QuickStartWizard error matching (services wrap errors in new Error(),
  losing err.response; now checks err.message as fallback)
- Add shellEscape() to all remaining unescaped CLI commands (removeUser,
  enableUser, disableUser, deletePolicy, attachPolicy, createPolicy)
- Fix inconsistent cookie secure flag in logout endpoint
- Add HTML escaping in email report templates to prevent XSS
- Add IP validation on token refresh to enforce IP binding
- Harden email recipient validation against header injection
- Add CSV injection prevention in report export
- Add JWT_SECRET minimum length validation (32 chars) in production
- Update bcrypt 5.x→6.x, nodemailer 6.x→8.x, fix all backend npm vulns
- Redesign UI: new theme (IBM Plex Sans, MinIO-inspired palette), improved
  login page, dashboard with skeleton loaders, refined sidebar navigation
- Add missing i18n keys for login and user menu (DE + EN)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 10:24:57 +01:00
parent 8a2a9d8887
commit a96f12dcb6
18 changed files with 942 additions and 985 deletions
+165 -487
View File
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -14,11 +14,15 @@
"generate-password": "node generate-password.js",
"postinstall": "npm rebuild bcrypt --build-from-source"
},
"keywords": ["minio", "api", "backend"],
"keywords": [
"minio",
"api",
"backend"
],
"author": "",
"license": "MIT",
"dependencies": {
"bcrypt": "^5.1.1",
"bcrypt": "^6.0.0",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
@@ -30,11 +34,11 @@
"ip-range-check": "^0.2.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.8",
"nodemailer": "^8.0.2",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^4.7.1",
"multer": "^1.4.5-lts.1"
"winston-daily-rotate-file": "^4.7.1"
},
"devDependencies": {
"eslint": "^8.56.0",
@@ -45,4 +49,4 @@
"engines": {
"node": ">=18.0.0"
}
}
}
+16 -5
View File
@@ -1,6 +1,7 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const authService = require('../../services/auth.service');
const config = require('../../config');
const { logger, logAudit } = require('../../utils/logger');
const { AppError } = require('../../middleware/errorHandler.middleware');
@@ -74,10 +75,10 @@ router.post('/login', validateLogin, handleValidationErrors, async (req, res, ne
router.post('/logout', (req, res) => {
const clientIp = req.ip;
// Clear cookie
// Clear cookie - use same options as login for consistency
res.clearCookie('token', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
secure: config.app.env === 'production',
sameSite: 'strict',
path: '/',
});
@@ -127,9 +128,19 @@ router.post('/refresh', (req, res, next) => {
}
const decoded = authService.verifyToken(token);
// Create new token with same data
const newSession = authService.createSession(decoded.ip || req.ip);
// Validate IP binding on refresh
if (decoded.ip && decoded.ip !== req.ip) {
logAudit('TOKEN_REFRESH_REJECTED', {
ip: req.ip,
status: 'failed',
details: { reason: 'IP mismatch', originalIp: decoded.ip },
});
throw new AppError('Token IP mismatch', 401);
}
// Create new token with current IP
const newSession = authService.createSession(req.ip);
// Set new cookie
res.cookie('token', newSession.token, authService.getCookieOptions());
+17 -4
View File
@@ -40,7 +40,13 @@ router.post('/generate',
.isArray().withMessage('Recipients must be an array')
.custom((value) => {
if (value && value.length > 0) {
return value.every(email => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email));
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return value.every(email =>
emailRegex.test(email) &&
!email.includes('\n') &&
!email.includes('\r') &&
email.length <= 254
);
}
return true;
}).withMessage('Invalid email address in recipients'),
@@ -129,13 +135,20 @@ router.get('/storage/export', async (req, res, next) => {
const report = await reportService.generateReport();
if (format === 'csv') {
// Sanitize CSV values to prevent CSV injection (formula injection)
const csvSafe = (val) => {
const str = String(val ?? '');
if (/^[=+\-@\t\r]/.test(str)) return `'${str}`;
return str.includes(',') || str.includes('"') ? `"${str.replace(/"/g, '""')}"` : str;
};
const csv = [
'Bucket Name,Size (Bytes),Size (Formatted),Objects,Last Modified',
...report.buckets.map(b =>
`"${b.name}",${b.size},"${b.sizeFormatted}",${b.objects},"${b.lastModified}"`
...report.buckets.map(b =>
`${csvSafe(b.name)},${b.size},${csvSafe(b.sizeFormatted)},${b.objects},${csvSafe(b.lastModified)}`
),
'',
`Total,${report.summary.totalSize},"${report.summary.totalSizeFormatted}",,`,
`Total,${report.summary.totalSize},${csvSafe(report.summary.totalSizeFormatted)},,`,
].join('\n');
res.setHeader('Content-Type', 'text/csv');
+2 -1
View File
@@ -1,6 +1,7 @@
const express = require('express');
const { body, param, validationResult } = require('express-validator');
const MinIOService = require('../../services/minio.service');
const shellEscape = MinIOService.shellEscape;
const { authMiddleware } = require('../../middleware/auth.middleware');
const { logger, logAudit } = require('../../utils/logger');
const { AppError } = require('../../middleware/errorHandler.middleware');
@@ -66,7 +67,7 @@ router.post('/',
// Create user without bucket (admin can assign policies later)
await minioService.executeCommand(
`mc admin user add ${minioService.alias} ${username} ${password}`
`mc admin user add ${minioService.alias} ${shellEscape(username)} ${shellEscape(password)}`
);
logAudit('USER_CREATE', {
+5
View File
@@ -72,6 +72,11 @@ const validateConfig = () => {
if (missing.length > 0) {
throw new Error(`Missing required configuration: ${missing.join(', ')}`);
}
// Validate JWT secret strength
if (config.auth.jwtSecret && config.auth.jwtSecret.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters long for security');
}
};
// Only validate in production
+13 -12
View File
@@ -145,7 +145,7 @@ class MinIOService {
if (error.stderr.includes('Access Denied')) {
throw new AppError('Access denied', 403);
}
if (error.stderr.includes('already exists')) {
if (error.stderr.includes('already exists') || error.stderr.includes('already own')) {
throw new AppError('Resource already exists', 409);
}
if (error.stderr.includes('policy')) {
@@ -296,10 +296,10 @@ class MinIOService {
// Create and attach policy
await this.executeCommand(
`mc admin policy create ${this.alias} ${policyName} ${policyFile}`
`mc admin policy create ${this.alias} ${shellEscape(policyName)} ${shellEscape(policyFile)}`
);
await this.executeCommand(
`mc admin policy attach ${this.alias} ${policyName} --user ${username}`
`mc admin policy attach ${this.alias} ${shellEscape(policyName)} --user ${shellEscape(username)}`
);
} finally {
// Clean up temp file
@@ -338,17 +338,17 @@ class MinIOService {
}
async removeUser(username) {
await this.executeCommand(`mc admin user remove ${this.alias} ${username}`);
await this.executeCommand(`mc admin user remove ${this.alias} ${shellEscape(username)}`);
return { message: 'User removed successfully', username };
}
async enableUser(username) {
await this.executeCommand(`mc admin user enable ${this.alias} ${username}`);
await this.executeCommand(`mc admin user enable ${this.alias} ${shellEscape(username)}`);
return { message: 'User enabled successfully', username };
}
async disableUser(username) {
await this.executeCommand(`mc admin user disable ${this.alias} ${username}`);
await this.executeCommand(`mc admin user disable ${this.alias} ${shellEscape(username)}`);
return { message: 'User disabled successfully', username };
}
@@ -402,9 +402,9 @@ class MinIOService {
await fs.access(policyFile, fsConstants.R_OK);
await this.executeCommand(
`mc admin policy create ${this.alias} ${policyName} ${policyFile}`
`mc admin policy create ${this.alias} ${shellEscape(policyName)} ${shellEscape(policyFile)}`
);
return { message: 'Policy created successfully', policyName };
} catch (error) {
// Clean up file if it exists
@@ -417,13 +417,13 @@ class MinIOService {
}
async deletePolicy(policyName) {
await this.executeCommand(`mc admin policy remove ${this.alias} ${policyName}`);
await this.executeCommand(`mc admin policy remove ${this.alias} ${shellEscape(policyName)}`);
return { message: 'Policy deleted successfully', policyName };
}
async attachPolicy(policyName, username) {
await this.executeCommand(
`mc admin policy attach ${this.alias} ${policyName} --user ${username}`
`mc admin policy attach ${this.alias} ${shellEscape(policyName)} --user ${shellEscape(username)}`
);
return { message: 'Policy attached successfully', policyName, username };
}
@@ -594,7 +594,7 @@ class MinIOService {
throw new AppError('Invalid alias name', 400);
}
await this.executeCommand(`mc alias remove ${aliasName}`);
await this.executeCommand(`mc alias remove ${shellEscape(aliasName)}`);
return { message: 'Alias removed successfully', aliasName };
}
@@ -830,4 +830,5 @@ class MinIOService {
}
}
module.exports = MinIOService;
module.exports = MinIOService;
module.exports.shellEscape = shellEscape;
+16 -5
View File
@@ -4,6 +4,17 @@ const MinIOService = require('./minio.service');
const config = require('../config');
const { logger } = require('../utils/logger');
// Escape HTML entities to prevent XSS in email reports
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
class ReportService {
constructor() {
this.minioService = new MinIOService();
@@ -170,10 +181,10 @@ class ReportService {
<tbody>
${report.buckets.map(bucket => `
<tr>
<td>${bucket.name}</td>
<td>${bucket.sizeFormatted}</td>
<td>${bucket.objects}</td>
<td>${bucket.lastModified}</td>
<td>${escapeHtml(bucket.name)}</td>
<td>${escapeHtml(bucket.sizeFormatted)}</td>
<td>${escapeHtml(bucket.objects)}</td>
<td>${escapeHtml(bucket.lastModified)}</td>
</tr>
`).join('')}
</tbody>
@@ -181,7 +192,7 @@ class ReportService {
<h2>Benutzer</h2>
<ul>
${report.users.map(user => `<li>${user}</li>`).join('')}
${report.users.map(user => `<li>${escapeHtml(user)}</li>`).join('')}
</ul>
<div class="footer">