Security: WS-Rollenprüfung, JWT-Cookie statt localStorage, XSS/SSRF-Fixes, RDP-Consent-Secret
- WebSocket Shell/RDP: Rollenprüfung statt nur JWT-Gültigkeit (war: jeder eingeloggte User konnte fremde Agents per Shell/RDP übernehmen) - JWT_SECRET: Server bricht ab statt mit unsicherem Default weiterzulaufen - Auth: Token läuft jetzt über httpOnly-Cookie statt localStorage (XSS-Schutz gegen Session-Diebstahl) - WS-Auth: Token nicht mehr als URL-Query-Param (landete in nginx-Logs), sondern als erste Message bzw. automatisch via Cookie - Frontend: toter Rollen-Check (isSuperAdmin/isAdmin ohne Funktionsaufruf) in AgentDetailPage gefixt - XSS: DOMPurify-Sanitizing für alle marked.parse()-Renderstellen (KI-Antworten, Kommentare, Knowledge Base) - E-Mail: HTML-Escaping für alle ticket-gesteuerten Felder (auch über öffentliche Ticket-Route erreichbar) - SSRF-Schutz beim Knowledge-Base-URL-Import (blockt private/Loopback-Adressen) - TV-Dashboard: Shared-Key statt komplett offenem Endpoint - Striktes Rate-Limit auf /login, must_change_password serverseitig erzwungen - Agent (C#) v2.7.0: RDP-Consent/Disconnect/Capture verlangen jetzt ein Pro-Session-Secret (war: jeder lokale Prozess konnte Consent vortäuschen), DataDir-ACL für agent.log/status.json - FIDO-PINs AES-256-GCM-verschlüsselt statt Klartext, Retention-Job für alte patch_commands/audit_log Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
20
backend/package-lock.json
generated
20
backend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"botbuilder": "^4.23.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
@@ -1371,6 +1372,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"botbuilder": "^4.23.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
require('dotenv').config();
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.error('❌ FATAL: JWT_SECRET ist nicht gesetzt. Server wird nicht mit einem unsicheren Default-Secret gestartet.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const JWT_CONFIG = {
|
||||
secret: process.env.JWT_SECRET || 'default-secret-change-in-production',
|
||||
secret: process.env.JWT_SECRET,
|
||||
expiresIn: process.env.JWT_EXPIRATION || '8h',
|
||||
algorithm: 'HS256'
|
||||
};
|
||||
|
||||
// Validate that JWT_SECRET is set
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.warn('⚠️ WARNING: JWT_SECRET not set in .env file. Using default secret (INSECURE!)');
|
||||
}
|
||||
|
||||
module.exports = JWT_CONFIG;
|
||||
|
||||
@@ -160,8 +160,12 @@ class AiController {
|
||||
const { url } = req.body;
|
||||
if (!url?.trim()) throw new AppError('URL ist erforderlich', 400);
|
||||
|
||||
// Only allow http/https
|
||||
if (!/^https?:\/\//i.test(url)) throw new AppError('Nur HTTP/HTTPS URLs erlaubt', 400);
|
||||
const { assertPublicUrl } = require('../utils/ssrfGuard');
|
||||
try {
|
||||
await assertPublicUrl(url);
|
||||
} catch (e) {
|
||||
throw new AppError(e.message, 400);
|
||||
}
|
||||
|
||||
let html;
|
||||
try {
|
||||
@@ -204,7 +208,12 @@ class AiController {
|
||||
const AiService = require('../services/ai.service');
|
||||
const { url, maxPages = 20 } = req.body;
|
||||
if (!url?.trim()) throw new AppError('URL ist erforderlich', 400);
|
||||
if (!/^https?:\/\//i.test(url)) throw new AppError('Nur HTTP/HTTPS URLs erlaubt', 400);
|
||||
const { assertPublicUrl } = require('../utils/ssrfGuard');
|
||||
try {
|
||||
await assertPublicUrl(url);
|
||||
} catch (e) {
|
||||
throw new AppError(e.message, 400);
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(1, parseInt(maxPages) || 20), 100);
|
||||
const baseUrl = new URL(url);
|
||||
@@ -236,6 +245,7 @@ class AiController {
|
||||
visited.add(currentUrl);
|
||||
|
||||
try {
|
||||
try { await assertPublicUrl(currentUrl); } catch { continue; } // DNS-Rebinding-Schutz
|
||||
const response = await fetch(currentUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 IT-Nexus KnowledgeBase Importer' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const AuthService = require('../services/auth.service');
|
||||
const User = require('../models/User');
|
||||
const { asyncHandler } = require('../middleware/errorHandler');
|
||||
const { setAuthCookie, clearAuthCookie } = require('../utils/authCookie');
|
||||
|
||||
class AuthController {
|
||||
/**
|
||||
@@ -18,10 +19,11 @@ class AuthController {
|
||||
}
|
||||
|
||||
const result = await AuthService.login(username, password);
|
||||
setAuthCookie(res, result.token);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
data: result
|
||||
data: { user: result.user }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,8 +106,7 @@ class AuthController {
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
static logout = asyncHandler(async (req, res) => {
|
||||
// Client-side will handle token removal
|
||||
// This endpoint is just for consistency and potential future server-side session handling
|
||||
clearAuthCookie(res);
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: 'Logged out successfully'
|
||||
|
||||
@@ -481,6 +481,7 @@ async function initializeDatabase() {
|
||||
`ALTER TABLE fido_keys ADD COLUMN last_used_at DATETIME`,
|
||||
`ALTER TABLE fido_keys ADD COLUMN manufacturer TEXT`,
|
||||
`ALTER TABLE fido_keys ADD COLUMN connection_type TEXT`,
|
||||
`ALTER TABLE fido_keys ADD COLUMN pin TEXT`,
|
||||
// Asset-Agent-Sync
|
||||
`ALTER TABLE assets ADD COLUMN os TEXT`,
|
||||
`ALTER TABLE assets ADD COLUMN ip_address TEXT`,
|
||||
@@ -552,6 +553,23 @@ async function initializeDatabase() {
|
||||
}
|
||||
console.log('✅ Database migrations completed');
|
||||
|
||||
// Special migration: bestehende Klartext-PINs in fido_keys nachverschlüsseln (DSGVO Art. 32)
|
||||
try {
|
||||
const { encrypt } = require('../utils/crypto');
|
||||
const plainPinRows = db.prepare(
|
||||
`SELECT id, pin FROM fido_keys WHERE pin IS NOT NULL AND pin != '' AND instr(pin, ':') = 0`
|
||||
).all();
|
||||
if (plainPinRows.length > 0) {
|
||||
const updatePin = db.prepare('UPDATE fido_keys SET pin = ? WHERE id = ?');
|
||||
for (const row of plainPinRows) {
|
||||
updatePin.run(encrypt(row.pin), row.id);
|
||||
}
|
||||
console.log(`🔐 ${plainPinRows.length} Klartext-PIN(s) in fido_keys nachverschlüsselt`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('⚠️ PIN-Verschlüsselungs-Migration fehlgeschlagen:', e.message);
|
||||
}
|
||||
|
||||
// Special migration: rebuild network_devices to add SNMP + AP support
|
||||
try {
|
||||
const ndDef = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='network_devices'").get();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const JWT_CONFIG = require('../config/jwt');
|
||||
|
||||
// Endpunkte, die trotz erzwungenem Passwortwechsel erreichbar bleiben müssen
|
||||
const PASSWORD_CHANGE_EXEMPT_PATHS = ['/api/auth/me', '/api/auth/change-password'];
|
||||
|
||||
/**
|
||||
* Middleware to verify JWT token and attach user to request
|
||||
*/
|
||||
@@ -18,6 +21,19 @@ function authenticateToken(req, res, next) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_CONFIG.secret);
|
||||
req.user = decoded; // { id, username, email, role, roleId }
|
||||
|
||||
if (!PASSWORD_CHANGE_EXEMPT_PATHS.includes(req.originalUrl.split('?')[0])) {
|
||||
const User = require('../models/User');
|
||||
const dbUser = User.getById(decoded.id);
|
||||
if (dbUser?.must_change_password) {
|
||||
return res.status(403).json({
|
||||
status: 'error',
|
||||
code: 'PASSWORD_CHANGE_REQUIRED',
|
||||
message: 'Passwortänderung erforderlich, bevor weitere Aktionen möglich sind'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
|
||||
@@ -105,6 +105,16 @@ class AuditLog {
|
||||
return stmt.all(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht Audit-Log-Einträge älter als retentionDays (DSGVO Art. 5 Abs. 1 lit. e - Speicherbegrenzung)
|
||||
*/
|
||||
static cleanupOld(retentionDays = 180) {
|
||||
const db = getDatabase();
|
||||
const stmt = db.prepare(`DELETE FROM audit_log WHERE created_at < datetime('now', '-' || ? || ' days')`);
|
||||
const result = stmt.run(retentionDays);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to log user actions
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
const { getDatabase } = require('../config/database');
|
||||
const { encrypt, decrypt } = require('../utils/crypto');
|
||||
|
||||
function withDecryptedPin(row) {
|
||||
if (!row) return row;
|
||||
return { ...row, pin: decrypt(row.pin) };
|
||||
}
|
||||
|
||||
class FidoKey {
|
||||
/**
|
||||
@@ -19,7 +25,7 @@ class FidoKey {
|
||||
LEFT JOIN users uu ON fk.updated_by_user_id = uu.id
|
||||
ORDER BY fk.created_at DESC
|
||||
`);
|
||||
return stmt.all();
|
||||
return stmt.all().map(withDecryptedPin);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,7 +46,7 @@ class FidoKey {
|
||||
LEFT JOIN users uu ON fk.updated_by_user_id = uu.id
|
||||
WHERE fk.id = ?
|
||||
`);
|
||||
return stmt.get(id);
|
||||
return withDecryptedPin(stmt.get(id));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,7 +64,7 @@ class FidoKey {
|
||||
LEFT JOIN users cu ON fk.created_by_user_id = cu.id
|
||||
WHERE fk.serial_number = ?
|
||||
`);
|
||||
return stmt.get(serialNumber);
|
||||
return withDecryptedPin(stmt.get(serialNumber));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +83,7 @@ class FidoKey {
|
||||
WHERE fk.status = ?
|
||||
ORDER BY fk.created_at DESC
|
||||
`);
|
||||
return stmt.all(status);
|
||||
return stmt.all(status).map(withDecryptedPin);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,7 +100,7 @@ class FidoKey {
|
||||
WHERE fk.assigned_to_user_id = ?
|
||||
ORDER BY fk.created_at DESC
|
||||
`);
|
||||
return stmt.all(userId);
|
||||
return stmt.all(userId).map(withDecryptedPin);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,9 +114,10 @@ class FidoKey {
|
||||
serial_number,
|
||||
status,
|
||||
description,
|
||||
pin,
|
||||
assigned_to_user_id,
|
||||
created_by_user_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
@@ -118,6 +125,7 @@ class FidoKey {
|
||||
keyData.serial_number,
|
||||
keyData.status,
|
||||
keyData.description || null,
|
||||
encrypt(keyData.pin) || null,
|
||||
keyData.assigned_to_user_id || null,
|
||||
keyData.created_by_user_id
|
||||
);
|
||||
@@ -150,6 +158,10 @@ class FidoKey {
|
||||
fields.push('description = ?');
|
||||
values.push(keyData.description);
|
||||
}
|
||||
if (keyData.pin !== undefined) {
|
||||
fields.push('pin = ?');
|
||||
values.push(encrypt(keyData.pin));
|
||||
}
|
||||
if (keyData.assigned_to_user_id !== undefined) {
|
||||
fields.push('assigned_to_user_id = ?');
|
||||
values.push(keyData.assigned_to_user_id);
|
||||
|
||||
@@ -3,6 +3,7 @@ const router = express.Router();
|
||||
const crypto = require('crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const User = require('../models/User');
|
||||
const { setAuthCookie } = require('../utils/authCookie');
|
||||
|
||||
const TENANT_ID = () => process.env.AZURE_TENANT_ID;
|
||||
const CLIENT_ID = () => process.env.AZURE_CLIENT_ID;
|
||||
|
||||
@@ -2,7 +2,15 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getStats, getCVEs } = require('../controllers/tv.controller');
|
||||
|
||||
router.get('/stats', getStats);
|
||||
router.get('/cves', getCVEs);
|
||||
// Kein normaler Login (TV-Display im Büro) — aber ein Shared-Key statt komplett offen ins Netz.
|
||||
function requireTvKey(req, res, next) {
|
||||
if (!process.env.TV_DASHBOARD_KEY || req.query.key !== process.env.TV_DASHBOARD_KEY) {
|
||||
return res.status(401).json({ status: 'error', message: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
router.get('/stats', requireTvKey, getStats);
|
||||
router.get('/cves', requireTvKey, getCVEs);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -75,6 +75,16 @@ const authLimiter = rateLimit({
|
||||
legacyHeaders: false
|
||||
});
|
||||
|
||||
// Striktes Limit nur für /login — verhindert Brute-Force auf Passwörter
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 8,
|
||||
message: { status: 'error', message: 'Zu viele Login-Versuche, bitte später erneut versuchen' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: true
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ROUTES
|
||||
// ============================================================================
|
||||
@@ -127,6 +137,7 @@ app.get('/api/health/history', (req, res) => {
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api/auth/login', loginLimiter);
|
||||
app.use('/api/auth', authLimiter, authRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/fido-keys', fidoKeyRoutes);
|
||||
@@ -411,6 +422,12 @@ async function startServer() {
|
||||
NetworkDevice.cleanupOldChecks();
|
||||
});
|
||||
|
||||
// DSGVO-Speicherbegrenzung: alte patch_commands (inkl. Shell-Output) + audit_log (täglich 03:30)
|
||||
cron.schedule('30 3 * * *', () => {
|
||||
const { runRetentionCleanup } = require('./services/dataRetention.service');
|
||||
runRetentionCleanup();
|
||||
});
|
||||
|
||||
// Proxmox Monitoring (alle 5 Minuten)
|
||||
if (process.env.PROXMOX_HOST && process.env.PROXMOX_TOKEN) {
|
||||
const { pollProxmox } = require('./services/proxmoxService');
|
||||
|
||||
26
backend/src/services/dataRetention.service.js
Normal file
26
backend/src/services/dataRetention.service.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const { getDatabase } = require('../config/database');
|
||||
const AuditLog = require('../models/AuditLog');
|
||||
|
||||
// DSGVO Art. 5 Abs. 1 lit. e (Speicherbegrenzung) — Daten nur so lange aufbewahren wie nötig.
|
||||
const PATCH_COMMANDS_RETENTION_DAYS = parseInt(process.env.PATCH_COMMANDS_RETENTION_DAYS || '90', 10);
|
||||
const AUDIT_LOG_RETENTION_DAYS = parseInt(process.env.AUDIT_LOG_RETENTION_DAYS || '180', 10);
|
||||
|
||||
function cleanupPatchCommands() {
|
||||
const db = getDatabase();
|
||||
const stmt = db.prepare(
|
||||
`DELETE FROM patch_commands WHERE created_at < datetime('now', '-' || ? || ' days') AND status IN ('done', 'failed')`
|
||||
);
|
||||
return stmt.run(PATCH_COMMANDS_RETENTION_DAYS).changes;
|
||||
}
|
||||
|
||||
function runRetentionCleanup() {
|
||||
try {
|
||||
const patchDeleted = cleanupPatchCommands();
|
||||
const auditDeleted = AuditLog.cleanupOld(AUDIT_LOG_RETENTION_DAYS);
|
||||
console.log(`[DataRetention] Bereinigt: ${patchDeleted} patch_commands (>${PATCH_COMMANDS_RETENTION_DAYS}d), ${auditDeleted} audit_log Einträge (>${AUDIT_LOG_RETENTION_DAYS}d)`);
|
||||
} catch (err) {
|
||||
console.error('[DataRetention] Fehler:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { runRetentionCleanup, cleanupPatchCommands };
|
||||
@@ -431,9 +431,9 @@ async function sendTicketCreatedConfirmation(ticket) {
|
||||
</p>
|
||||
|
||||
${infoCard([
|
||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${ticket.ticket_number}</strong>`],
|
||||
['Betreff', `<strong style="color:#111827;">${ticket.title}</strong>`],
|
||||
['Kategorie', `<span style="color:#374151;">${ticket.category}</span>`],
|
||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${escHtml(ticket.ticket_number)}</strong>`],
|
||||
['Betreff', `<strong style="color:#111827;">${escHtml(ticket.title)}</strong>`],
|
||||
['Kategorie', `<span style="color:#374151;">${escHtml(ticket.category)}</span>`],
|
||||
['Priorität', priorityBadge(ticket.priority)],
|
||||
['Status', statusBadge(ticket.status)],
|
||||
])}
|
||||
@@ -494,11 +494,11 @@ async function sendTicketAssignedNotification(ticket, assignedUser) {
|
||||
</p>
|
||||
|
||||
${infoCard([
|
||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${ticket.ticket_number}</strong>`],
|
||||
['Betreff', `<strong style="color:#111827;">${ticket.title}</strong>`],
|
||||
['Kategorie', `<span style="color:#374151;">${ticket.category}</span>`],
|
||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${escHtml(ticket.ticket_number)}</strong>`],
|
||||
['Betreff', `<strong style="color:#111827;">${escHtml(ticket.title)}</strong>`],
|
||||
['Kategorie', `<span style="color:#374151;">${escHtml(ticket.category)}</span>`],
|
||||
['Priorität', priorityBadge(ticket.priority)],
|
||||
['Von', `<span style="color:#374151;">${ticket.requester_name || 'Unbekannt'}${ticket.requester_email ? ` <${ticket.requester_email}>` : ''}</span>`],
|
||||
['Von', `<span style="color:#374151;">${escHtml(ticket.requester_name || 'Unbekannt')}${ticket.requester_email ? ` <${escHtml(ticket.requester_email)}>` : ''}</span>`],
|
||||
])}`;
|
||||
|
||||
await sendMail(
|
||||
@@ -554,10 +554,10 @@ async function sendCommentNotification(ticket, comment) {
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td bgcolor="#0d9488" style="background-color:#0d9488;border-radius:6px;width:28px;height:28px;text-align:center;vertical-align:middle;padding:0 8px;">
|
||||
<span style="font-size:13px;font-weight:800;color:#ffffff;font-family:Inter,Helvetica,Arial,sans-serif;line-height:28px;">${authorName.charAt(0).toUpperCase()}</span>
|
||||
<span style="font-size:13px;font-weight:800;color:#ffffff;font-family:Inter,Helvetica,Arial,sans-serif;line-height:28px;">${escHtml(authorName.charAt(0).toUpperCase())}</span>
|
||||
</td>
|
||||
<td style="padding-left:10px;vertical-align:middle;">
|
||||
<span style="font-size:13px;font-weight:700;color:#111827;font-family:Inter,Helvetica,Arial,sans-serif;">${authorName}</span>
|
||||
<span style="font-size:13px;font-weight:700;color:#111827;font-family:Inter,Helvetica,Arial,sans-serif;">${escHtml(authorName)}</span>
|
||||
<span style="font-size:11px;color:#9ca3af;font-family:Inter,Helvetica,Arial,sans-serif;padding-left:6px;">· IT Support</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -567,14 +567,14 @@ async function sendCommentNotification(ticket, comment) {
|
||||
<!-- Message body -->
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" style="background-color:#ffffff;padding:16px;font-size:14px;color:#1f2937;line-height:1.75;white-space:pre-line;font-family:Inter,Helvetica,Arial,sans-serif;">
|
||||
${comment.comment.replace(/</g, '<').replace(/>/g, '>')}
|
||||
${escHtml(comment.comment)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${infoCard([
|
||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${ticket.ticket_number}</strong>`],
|
||||
['Betreff', `<span style="color:#374151;">${ticket.title}</span>`],
|
||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${escHtml(ticket.ticket_number)}</strong>`],
|
||||
['Betreff', `<span style="color:#374151;">${escHtml(ticket.title)}</span>`],
|
||||
['Status', statusBadge(ticket.status)],
|
||||
])}`;
|
||||
|
||||
@@ -611,19 +611,19 @@ async function sendStaffCommentNotification(ticket, comment) {
|
||||
const content = `
|
||||
<h2 style="margin:0 0 8px;font-size:21px;font-weight:800;color:#111827;font-family:Inter,Helvetica,Arial,sans-serif;">Neue Antwort im Ticket</h2>
|
||||
<p style="margin:0 0 20px;font-size:14px;color:#6b7280;line-height:1.7;font-family:Inter,Helvetica,Arial,sans-serif;">
|
||||
<strong>${requesterName}</strong> hat auf Ticket <strong style="color:#0d9488;">${ticket.ticket_number}</strong> geantwortet.
|
||||
<strong>${escHtml(requesterName)}</strong> hat auf Ticket <strong style="color:#0d9488;">${escHtml(ticket.ticket_number)}</strong> geantwortet.
|
||||
</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;border:1px solid #e5e7eb;border-radius:10px;overflow:hidden;margin-bottom:22px;">
|
||||
<tr><td bgcolor="#f8fafc" style="background-color:#f8fafc;padding:10px 16px;border-bottom:1px solid #e5e7eb;">
|
||||
<span style="font-size:13px;font-weight:700;color:#111827;font-family:Inter,Helvetica,Arial,sans-serif;">${requesterName}</span>
|
||||
<span style="font-size:13px;font-weight:700;color:#111827;font-family:Inter,Helvetica,Arial,sans-serif;">${escHtml(requesterName)}</span>
|
||||
</td></tr>
|
||||
<tr><td bgcolor="#ffffff" style="background-color:#ffffff;padding:16px;font-size:14px;color:#1f2937;line-height:1.75;white-space:pre-line;font-family:Inter,Helvetica,Arial,sans-serif;">
|
||||
${comment.comment.replace(/</g, '<').replace(/>/g, '>')}
|
||||
${escHtml(comment.comment)}
|
||||
</td></tr>
|
||||
</table>
|
||||
${infoCard([
|
||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${ticket.ticket_number}</strong>`],
|
||||
['Betreff', `<span style="color:#374151;">${ticket.title}</span>`],
|
||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${escHtml(ticket.ticket_number)}</strong>`],
|
||||
['Betreff', `<span style="color:#374151;">${escHtml(ticket.title)}</span>`],
|
||||
['Status', statusBadge(ticket.status)],
|
||||
])}`;
|
||||
|
||||
@@ -660,10 +660,10 @@ async function sendStaffTicketCreatedNotification(ticket) {
|
||||
const content = `
|
||||
<h2 style="margin:0 0 8px;font-size:21px;font-weight:800;color:#111827;font-family:Inter,Helvetica,Arial,sans-serif;">Neues Ticket eingegangen</h2>
|
||||
${infoCard([
|
||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${ticket.ticket_number}</strong>`],
|
||||
['Betreff', `<strong style="color:#111827;">${ticket.title}</strong>`],
|
||||
['Von', `<span style="color:#374151;">${ticket.requester_name || ''}${ticket.requester_email ? ` <${ticket.requester_email}>` : ''}</span>`],
|
||||
['Kategorie', `<span style="color:#374151;">${ticket.category || '—'}</span>`],
|
||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${escHtml(ticket.ticket_number)}</strong>`],
|
||||
['Betreff', `<strong style="color:#111827;">${escHtml(ticket.title)}</strong>`],
|
||||
['Von', `<span style="color:#374151;">${escHtml(ticket.requester_name || '')}${ticket.requester_email ? ` <${escHtml(ticket.requester_email)}>` : ''}</span>`],
|
||||
['Kategorie', `<span style="color:#374151;">${escHtml(ticket.category || '—')}</span>`],
|
||||
['Priorität', priorityBadge(ticket.priority)],
|
||||
])}`;
|
||||
|
||||
@@ -735,8 +735,8 @@ async function sendStatusChangeNotification(ticket, oldStatus, newStatus) {
|
||||
${statusChangeVisual}
|
||||
|
||||
${infoCard([
|
||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${ticket.ticket_number}</strong>`],
|
||||
['Betreff', `<span style="color:#374151;">${ticket.title}</span>`],
|
||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${escHtml(ticket.ticket_number)}</strong>`],
|
||||
['Betreff', `<span style="color:#374151;">${escHtml(ticket.title)}</span>`],
|
||||
['Priorität', priorityBadge(ticket.priority)],
|
||||
])}
|
||||
|
||||
@@ -1032,10 +1032,10 @@ async function sendEscalationEmail(ticket) {
|
||||
${introHtml}
|
||||
</p>
|
||||
<table width="100%" cellpadding="12" style="background:#fff8f8;border:1px solid #fca5a5;border-radius:8px;margin:0 0 16px;">
|
||||
<tr><td><strong>Ticket:</strong> ${ticket.ticket_number}</td></tr>
|
||||
<tr><td><strong>Titel:</strong> ${ticket.title}</td></tr>
|
||||
<tr><td><strong>Ticket:</strong> ${escHtml(ticket.ticket_number)}</td></tr>
|
||||
<tr><td><strong>Titel:</strong> ${escHtml(ticket.title)}</td></tr>
|
||||
<tr><td><strong>Priorität:</strong> ${priorityBadge(ticket.priority)}</td></tr>
|
||||
<tr><td><strong>Ersteller:</strong> ${ticket.requester_name || ticket.requester_email || 'Unbekannt'}</td></tr>
|
||||
<tr><td><strong>Ersteller:</strong> ${escHtml(ticket.requester_name || ticket.requester_email || 'Unbekannt')}</td></tr>
|
||||
<tr><td><strong>Erstellt:</strong> ${new Date(ticket.created_at + 'Z').toLocaleString('de-DE')}</td></tr>
|
||||
</table>`;
|
||||
|
||||
|
||||
20
backend/src/utils/authCookie.js
Normal file
20
backend/src/utils/authCookie.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const COOKIE_NAME = 'token';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
// httpOnly-Cookie statt Token in JS-lesbarem localStorage — verhindert dass ein XSS-Treffer
|
||||
// das Session-Token einfach per document.cookie/localStorage ausliest.
|
||||
function setAuthCookie(res, token, maxAgeMs = 8 * 60 * 60 * 1000) {
|
||||
res.cookie(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: isProd,
|
||||
sameSite: 'lax',
|
||||
maxAge: maxAgeMs,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
function clearAuthCookie(res) {
|
||||
res.clearCookie(COOKIE_NAME, { httpOnly: true, secure: isProd, sameSite: 'lax', path: '/' });
|
||||
}
|
||||
|
||||
module.exports = { setAuthCookie, clearAuthCookie, COOKIE_NAME };
|
||||
34
backend/src/utils/crypto.js
Normal file
34
backend/src/utils/crypto.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
|
||||
function getKey() {
|
||||
const secret = process.env.ENCRYPTION_KEY || process.env.JWT_SECRET || 'itnexus-fallback-key';
|
||||
return crypto.createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
function encrypt(plainText) {
|
||||
if (plainText === null || plainText === undefined || plainText === '') return null;
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(String(plainText), 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`;
|
||||
}
|
||||
|
||||
function decrypt(cipherText) {
|
||||
if (!cipherText) return null;
|
||||
const parts = cipherText.split(':');
|
||||
if (parts.length !== 3) return cipherText; // unverschlüsselter Altbestand
|
||||
try {
|
||||
const [ivB64, authTagB64, dataB64] = parts;
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), Buffer.from(ivB64, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
|
||||
const decrypted = Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]);
|
||||
return decrypted.toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { encrypt, decrypt };
|
||||
47
backend/src/utils/ssrfGuard.js
Normal file
47
backend/src/utils/ssrfGuard.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const dns = require('dns').promises;
|
||||
|
||||
function isPrivateIp(ip) {
|
||||
if (ip.includes(':')) {
|
||||
// IPv6: loopback, link-local, unique-local
|
||||
return ip === '::1' || /^fe80:/i.test(ip) || /^fc[0-9a-f]{2}:/i.test(ip) || /^fd[0-9a-f]{2}:/i.test(ip);
|
||||
}
|
||||
const parts = ip.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some(p => Number.isNaN(p))) return true; // unparsable → sicherheitshalber blocken
|
||||
const [a, b] = parts;
|
||||
if (a === 127) return true; // Loopback
|
||||
if (a === 10) return true; // Private
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // Private
|
||||
if (a === 192 && b === 168) return true; // Private
|
||||
if (a === 169 && b === 254) return true; // Link-local
|
||||
if (a === 0) return true; // "this network"
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wirft, falls die URL auf interne/private Adressen oder Loopback zeigt — verhindert SSRF
|
||||
// über den Knowledge-Base-URL-Import (Server würde sonst beliebige interne Endpunkte abrufen).
|
||||
async function assertPublicUrl(urlString) {
|
||||
const parsed = new URL(urlString);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
throw new Error('Nur HTTP/HTTPS URLs erlaubt');
|
||||
}
|
||||
const hostname = parsed.hostname;
|
||||
if (hostname === 'localhost' || hostname.endsWith('.local')) {
|
||||
throw new Error('Interne/lokale Adressen sind nicht erlaubt');
|
||||
}
|
||||
|
||||
let addresses;
|
||||
try {
|
||||
addresses = await dns.lookup(hostname, { all: true });
|
||||
} catch {
|
||||
throw new Error('Hostname konnte nicht aufgelöst werden');
|
||||
}
|
||||
|
||||
for (const { address } of addresses) {
|
||||
if (isPrivateIp(address)) {
|
||||
throw new Error('Interne/private Adressen sind nicht erlaubt');
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
module.exports = { assertPublicUrl, isPrivateIp };
|
||||
@@ -90,29 +90,38 @@ function handleAgent(ws, url) {
|
||||
});
|
||||
}
|
||||
|
||||
// Token kommt NICHT mehr als URL-Query-Param (landet sonst im Klartext in nginx-Access-Logs),
|
||||
// sondern als erste WS-Message ({type:'auth',token}) — erst danach wird die Verbindung freigeschaltet.
|
||||
function handleBrowser(ws, url) {
|
||||
const token = url.searchParams.get('token');
|
||||
try {
|
||||
jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch {
|
||||
ws.close(1008, 'unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = parseInt(url.searchParams.get('agentId'));
|
||||
if (!agentId) { ws.close(1008, 'agentId required'); return; }
|
||||
|
||||
browserSockets.set(agentId, ws);
|
||||
|
||||
const aws = agentSockets.get(agentId);
|
||||
if (aws?.readyState === WebSocket.OPEN) {
|
||||
aws.send(JSON.stringify({ type: 'start_shell' }));
|
||||
ws.send('\x1b[32m[Verbunden]\x1b[0m\r\n');
|
||||
} else {
|
||||
ws.send('\x1b[33m[Warte auf Agent-Verbindung...]\x1b[0m\r\n');
|
||||
}
|
||||
let authenticated = false;
|
||||
const authTimer = setTimeout(() => { if (!authenticated) ws.close(1008, 'auth timeout'); }, 5000);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
if (!authenticated) {
|
||||
clearTimeout(authTimer);
|
||||
let decoded;
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type !== 'auth') throw new Error();
|
||||
decoded = jwt.verify(msg.token, process.env.JWT_SECRET);
|
||||
} catch { ws.close(1008, 'unauthorized'); return; }
|
||||
if (!['admin', 'super_admin'].includes(decoded.role)) { ws.close(1008, 'forbidden'); return; }
|
||||
authenticated = true;
|
||||
|
||||
browserSockets.set(agentId, ws);
|
||||
const aws = agentSockets.get(agentId);
|
||||
if (aws?.readyState === WebSocket.OPEN) {
|
||||
aws.send(JSON.stringify({ type: 'start_shell' }));
|
||||
ws.send('\x1b[32m[Verbunden]\x1b[0m\r\n');
|
||||
} else {
|
||||
ws.send('\x1b[33m[Warte auf Agent-Verbindung...]\x1b[0m\r\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const aws = agentSockets.get(agentId);
|
||||
if (!aws || aws.readyState !== WebSocket.OPEN) return;
|
||||
// RTC-Signaling (offer/answer/ice) → transparent weiterleiten
|
||||
@@ -164,18 +173,30 @@ function handleRdpAgent(ws, url) {
|
||||
}
|
||||
|
||||
function handleRdpBrowser(ws, url) {
|
||||
const token = url.searchParams.get('token');
|
||||
try { jwt.verify(token, process.env.JWT_SECRET); }
|
||||
catch { ws.close(1008, 'unauthorized'); return; }
|
||||
|
||||
const agentId = parseInt(url.searchParams.get('agentId'));
|
||||
if (!agentId) { ws.close(1008, 'agentId required'); return; }
|
||||
|
||||
const oldBws = rdpBrowserSockets.get(agentId);
|
||||
if (oldBws?.readyState === WebSocket.OPEN) oldBws.close();
|
||||
rdpBrowserSockets.set(agentId, ws);
|
||||
let authenticated = false;
|
||||
const authTimer = setTimeout(() => { if (!authenticated) ws.close(1008, 'auth timeout'); }, 5000);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
if (!authenticated) {
|
||||
clearTimeout(authTimer);
|
||||
let decoded;
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type !== 'auth') throw new Error();
|
||||
decoded = jwt.verify(msg.token, process.env.JWT_SECRET);
|
||||
} catch { ws.close(1008, 'unauthorized'); return; }
|
||||
if (!['admin', 'super_admin'].includes(decoded.role)) { ws.close(1008, 'forbidden'); return; }
|
||||
authenticated = true;
|
||||
|
||||
const oldBws = rdpBrowserSockets.get(agentId);
|
||||
if (oldBws?.readyState === WebSocket.OPEN) oldBws.close();
|
||||
rdpBrowserSockets.set(agentId, ws);
|
||||
return;
|
||||
}
|
||||
|
||||
const aws = rdpAgentSockets.get(agentId);
|
||||
if (aws?.readyState === WebSocket.OPEN) aws.send(data.toString());
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user