fix: Security hardening, wizard bug fix, and UI redesign
ci/woodpecker/push/woodpecker Pipeline was successful
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:
Generated
+165
-487
File diff suppressed because it is too large
Load Diff
+10
-6
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user