Security: WS-Rollenprüfung, JWT-Cookie statt localStorage, XSS/SSRF-Fixes, RDP-Consent-Secret
Some checks failed
IT Nexus Deploy / Build Frontend (push) Has been cancelled
IT Nexus Deploy / Deploy to Production (push) Has been cancelled

- 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:
2026-06-26 13:27:17 +02:00
parent 3ea28def4c
commit 81b1c326fc
47 changed files with 706 additions and 235 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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),

View File

@@ -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'

View File

@@ -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();

View File

@@ -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') {

View File

@@ -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
*/

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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');

View 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 };

View File

@@ -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 ? ` &lt;${ticket.requester_email}&gt;` : ''}</span>`],
['Von', `<span style="color:#374151;">${escHtml(ticket.requester_name || 'Unbekannt')}${ticket.requester_email ? ` &lt;${escHtml(ticket.requester_email)}&gt;` : ''}</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, '&lt;').replace(/>/g, '&gt;')}
${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, '&lt;').replace(/>/g, '&gt;')}
${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 ? ` &lt;${ticket.requester_email}&gt;` : ''}</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 ? ` &lt;${escHtml(ticket.requester_email)}&gt;` : ''}</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>`;

View 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 };

View 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 };

View 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 };

View File

@@ -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());
});