diff --git a/.gitignore b/.gitignore index 4f4f530..8a22a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ agent/*.exe agent/*.msi agent-cs/bin/ agent-cs/obj/ +agent-cs/publish/ # Uploads backend/uploads/ diff --git a/agent-cs/AgentWorker.cs b/agent-cs/AgentWorker.cs index ba453e5..5144806 100644 --- a/agent-cs/AgentWorker.cs +++ b/agent-cs/AgentWorker.cs @@ -6,7 +6,7 @@ namespace ITNexusAgent; public class AgentWorker { - private const string Version = "2.6.0"; + private const string Version = "2.7.0"; private const string DataDir = @"C:\ProgramData\IT Nexus Agent"; private const string ConfigPath = @"C:\ProgramData\IT Nexus Agent\config.json"; private const string StatusPath = @"C:\ProgramData\IT Nexus Agent\status.json"; @@ -24,6 +24,7 @@ public class AgentWorker { _exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName; Directory.CreateDirectory(DataDir); + SecureDataDir(DataDir); if (!File.Exists(ConfigPath)) { @@ -176,6 +177,30 @@ public class AgentWorker catch { } } + // Verzeichnis-ACL: nur SYSTEM/Administratoren — verhindert dass normale lokale User + // agent.log/status.json lesen (Hostname, letzter User, RDP-/Patch-Aktivität) oder manipulieren. + private static void SecureDataDir(string dir) + { + try + { + var di = new System.IO.DirectoryInfo(dir); + var acl = di.GetAccessControl(); + acl.SetAccessRuleProtection(true, false); + acl.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule( + "SYSTEM", System.Security.AccessControl.FileSystemRights.FullControl, + System.Security.AccessControl.InheritanceFlags.ContainerInherit | System.Security.AccessControl.InheritanceFlags.ObjectInherit, + System.Security.AccessControl.PropagationFlags.None, + System.Security.AccessControl.AccessControlType.Allow)); + acl.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule( + "Administrators", System.Security.AccessControl.FileSystemRights.FullControl, + System.Security.AccessControl.InheritanceFlags.ContainerInherit | System.Security.AccessControl.InheritanceFlags.ObjectInherit, + System.Security.AccessControl.PropagationFlags.None, + System.Security.AccessControl.AccessControlType.Allow)); + di.SetAccessControl(acl); + } + catch { } + } + private static void SecureConfigFile(string path) { try diff --git a/agent-cs/CaptureModeRunner.cs b/agent-cs/CaptureModeRunner.cs index a1c598a..70088eb 100644 --- a/agent-cs/CaptureModeRunner.cs +++ b/agent-cs/CaptureModeRunner.cs @@ -11,7 +11,7 @@ public static class CaptureModeRunner private static readonly ImageCodecInfo JpegCodec = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid); - public static void Run(string portStr, int screenIdx = 0) + public static void Run(string portStr, int screenIdx, string secret) { if (!int.TryParse(portStr, out var port) || port <= 0) return; @@ -20,6 +20,7 @@ public static class CaptureModeRunner using var tcp = new TcpClient(); tcp.Connect("127.0.0.1", port); var stream = tcp.GetStream(); + stream.Write(System.Text.Encoding.ASCII.GetBytes(secret)); var encParams = new EncoderParameters(1); encParams.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, 60L); diff --git a/agent-cs/ConsentModeRunner.cs b/agent-cs/ConsentModeRunner.cs index 0c034b6..0fde538 100644 --- a/agent-cs/ConsentModeRunner.cs +++ b/agent-cs/ConsentModeRunner.cs @@ -5,14 +5,14 @@ namespace ITNexusAgent; // Läuft als User-Prozess (via schtasks), zeigt Consent-Dialog und sendet Antwort via TCP public static class ConsentModeRunner { - public static void Run(string portStr) + public static void Run(string portStr, string secret) { if (!int.TryParse(portStr, out var port) || port <= 0) return; var app = new System.Windows.Application(); app.ShutdownMode = System.Windows.ShutdownMode.OnMainWindowClose; - var win = new RdpConsentWindow(port); + var win = new RdpConsentWindow(port, secret); win.Topmost = true; win.Show(); win.Activate(); diff --git a/agent-cs/IT-Nexus-Agent.csproj b/agent-cs/IT-Nexus-Agent.csproj index 41454f7..ccf0953 100644 --- a/agent-cs/IT-Nexus-Agent.csproj +++ b/agent-cs/IT-Nexus-Agent.csproj @@ -7,8 +7,8 @@ true IT-Nexus-Agent ITNexusAgent - 2.6.0 - 2.6.0.0 + 2.7.0 + 2.7.0.0 enable enable false diff --git a/agent-cs/IndicatorModeRunner.cs b/agent-cs/IndicatorModeRunner.cs index 11fd3a1..fb8f2a8 100644 --- a/agent-cs/IndicatorModeRunner.cs +++ b/agent-cs/IndicatorModeRunner.cs @@ -6,14 +6,14 @@ namespace ITNexusAgent; // solange RDP-Session aktiv ist. User kann selbst trennen (TCP-Signal an Service). public static class IndicatorModeRunner { - public static void Run(string portStr) + public static void Run(string portStr, string secret) { if (!int.TryParse(portStr, out var port) || port <= 0) return; var app = new System.Windows.Application(); app.ShutdownMode = System.Windows.ShutdownMode.OnMainWindowClose; - var win = new RdpActiveIndicatorWindow(port); + var win = new RdpActiveIndicatorWindow(port, secret); win.Show(); app.Run(); } diff --git a/agent-cs/Program.cs b/agent-cs/Program.cs index c036b43..d8c12c0 100644 --- a/agent-cs/Program.cs +++ b/agent-cs/Program.cs @@ -20,17 +20,22 @@ internal class Program return; case "--rdp-consent": - ConsentModeRunner.Run(args.Length > 1 ? args[1] : ""); + ConsentModeRunner.Run( + args.Length > 1 ? args[1] : "", + args.Length > 2 ? args[2] : ""); return; case "--rdp-capture": CaptureModeRunner.Run( args.Length > 1 ? args[1] : "", - args.Length > 2 && int.TryParse(args[2], out var si) ? si : 0); + args.Length > 2 && int.TryParse(args[2], out var si) ? si : 0, + args.Length > 3 ? args[3] : ""); return; case "--rdp-indicator": - IndicatorModeRunner.Run(args.Length > 1 ? args[1] : ""); + IndicatorModeRunner.Run( + args.Length > 1 ? args[1] : "", + args.Length > 2 ? args[2] : ""); return; case "--dashboard": diff --git a/agent-cs/Services/RtcService.cs b/agent-cs/Services/RtcService.cs index 5095e4c..ad5ca02 100644 --- a/agent-cs/Services/RtcService.cs +++ b/agent-cs/Services/RtcService.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Net; using System.Net.Sockets; using System.Net.WebSockets; +using System.Security.Cryptography; using System.Text; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -112,15 +113,36 @@ public class RtcService } } + private static string GenerateSecret() => Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); + + // Liest exakt secret.Length Bytes und vergleicht zeitkonstant — verhindert, dass ein beliebiger + // lokaler Prozess sich als der gespawnte Helper ausgibt und Consent/Disconnect/Frames vortäuscht. + private static async Task ValidateSecretAsync(NetworkStream stream, string secret, CancellationToken ct) + { + var expected = Encoding.ASCII.GetBytes(secret); + var buf = new byte[expected.Length]; + var read = 0; + while (read < buf.Length) + { + int n; + try { n = await stream.ReadAsync(buf.AsMemory(read, buf.Length - read), ct); } + catch { return false; } + if (n == 0) return false; + read += n; + } + return CryptographicOperations.FixedTimeEquals(buf, expected); + } + private async Task ConsentAndCaptureAsync(ClientWebSocket ws, int screenIdx, CancellationToken ct) { var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName; + var secret = GenerateSecret(); var consentListener = new TcpListener(IPAddress.Loopback, 0); consentListener.Start(); var consentPort = ((IPEndPoint)consentListener.LocalEndpoint).Port; - if (!SpawnConsentHelper(exePath, consentPort.ToString())) + if (!SpawnConsentHelper(exePath, consentPort.ToString(), secret)) { consentListener.Stop(); AgentWorker.Log("RDP: Consent-Helper fehlgeschlagen, verweigere Zugriff"); @@ -140,9 +162,15 @@ public class RtcService { using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(35)); using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); - using var tcp = await consentListener.AcceptTcpClientAsync(linked.Token); - var b = tcp.GetStream().ReadByte(); - accepted = b == 1; + while (!linked.IsCancellationRequested) + { + using var tcp = await consentListener.AcceptTcpClientAsync(linked.Token); + var stream = tcp.GetStream(); + if (!await ValidateSecretAsync(stream, secret, linked.Token)) continue; // fremder Connect-Versuch ohne gültiges Secret — ignorieren, weiter warten + var b = stream.ReadByte(); + accepted = b == 1; + break; + } } catch { accepted = false; } finally { consentListener.Stop(); } @@ -174,6 +202,7 @@ public class RtcService _indicatorListener = new TcpListener(IPAddress.Loopback, 0); _indicatorListener.Start(); var port = ((IPEndPoint)_indicatorListener.LocalEndpoint).Port; + var secret = GenerateSecret(); _userDisconnectCts = new CancellationTokenSource(); var listener = _indicatorListener; @@ -182,15 +211,21 @@ public class RtcService { try { - using var tcp = await listener.AcceptTcpClientAsync(); - tcp.GetStream().ReadByte(); - disconnectCts.Cancel(); - AgentWorker.Log("RDP: User hat über Overlay getrennt"); + while (true) + { + using var tcp = await listener.AcceptTcpClientAsync(); + var stream = tcp.GetStream(); + if (!await ValidateSecretAsync(stream, secret, CancellationToken.None)) continue; // fremder Connect-Versuch ohne gültiges Secret + stream.ReadByte(); + disconnectCts.Cancel(); + AgentWorker.Log("RDP: User hat über Overlay getrennt"); + break; + } } catch { } }); - _indicatorPid = SessionSpawner.SpawnInUserSession(exePath, $"--rdp-indicator {port}"); + _indicatorPid = SessionSpawner.SpawnInUserSession(exePath, $"--rdp-indicator {port} {secret}"); } catch (Exception ex) { @@ -198,22 +233,23 @@ public class RtcService } } - private static bool SpawnConsentHelper(string exePath, string portStr) + private static bool SpawnConsentHelper(string exePath, string portStr, string secret) { - var pid = SessionSpawner.SpawnInUserSession(exePath, $"--rdp-consent {portStr}"); + var pid = SessionSpawner.SpawnInUserSession(exePath, $"--rdp-consent {portStr} {secret}"); return pid > 0; } private async Task CapturePipeLoopAsync(ClientWebSocket ws, int screenIdx, CancellationToken ct) { var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName; + var secret = GenerateSecret(); // TCP Loopback: kein ACL-Problem zwischen SYSTEM-Service und User-Prozess var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; - if (!SpawnCaptureHelper(exePath, port.ToString(), screenIdx)) + if (!SpawnCaptureHelper(exePath, port.ToString(), screenIdx, secret)) { listener.Stop(); AgentWorker.Log("RDP: Helper-Start fehlgeschlagen"); @@ -225,7 +261,16 @@ public class RtcService { using var connectCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, connectCts.Token); - tcp = await listener.AcceptTcpClientAsync(linked.Token); + while (true) + { + var candidate = await listener.AcceptTcpClientAsync(linked.Token); + if (await ValidateSecretAsync(candidate.GetStream(), secret, linked.Token)) + { + tcp = candidate; + break; + } + candidate.Dispose(); // fremder Connect-Versuch ohne gültiges Secret — verwerfen, weiter warten + } } catch { @@ -283,9 +328,9 @@ public class RtcService AgentWorker.Log("RDP: Frame-Loop beendet"); } - private static bool SpawnCaptureHelper(string exePath, string portStr, int screenIdx = 0) + private static bool SpawnCaptureHelper(string exePath, string portStr, int screenIdx, string secret) { - var pid = SessionSpawner.SpawnInUserSession(exePath, $"--rdp-capture {portStr} {screenIdx}"); + var pid = SessionSpawner.SpawnInUserSession(exePath, $"--rdp-capture {portStr} {screenIdx} {secret}"); return pid > 0; } } diff --git a/agent-cs/UI/RdpActiveIndicatorWindow.xaml.cs b/agent-cs/UI/RdpActiveIndicatorWindow.xaml.cs index 7036a7f..c891bfe 100644 --- a/agent-cs/UI/RdpActiveIndicatorWindow.xaml.cs +++ b/agent-cs/UI/RdpActiveIndicatorWindow.xaml.cs @@ -1,4 +1,5 @@ using System.Net.Sockets; +using System.Text; using System.Windows; using System.Windows.Media.Animation; @@ -7,14 +8,16 @@ namespace ITNexusAgent.UI; public partial class RdpActiveIndicatorWindow : Window { private readonly int _port; + private readonly string _secret; private readonly DateTime _startedAt = DateTime.Now; private readonly System.Windows.Threading.DispatcherTimer _timer = new(); private bool _signaled = false; - public RdpActiveIndicatorWindow(int port) + public RdpActiveIndicatorWindow(int port, string secret) { InitializeComponent(); _port = port; + _secret = secret; Loaded += (s, e) => { @@ -47,8 +50,10 @@ public partial class RdpActiveIndicatorWindow : Window { using var tcp = new TcpClient(); tcp.Connect("127.0.0.1", _port); - tcp.GetStream().WriteByte(1); - tcp.GetStream().Flush(); + var stream = tcp.GetStream(); + stream.Write(Encoding.ASCII.GetBytes(_secret)); + stream.WriteByte(1); + stream.Flush(); } catch { } } diff --git a/agent-cs/UI/RdpConsentWindow.xaml.cs b/agent-cs/UI/RdpConsentWindow.xaml.cs index 6b4c5a7..5ed1f7e 100644 --- a/agent-cs/UI/RdpConsentWindow.xaml.cs +++ b/agent-cs/UI/RdpConsentWindow.xaml.cs @@ -1,4 +1,5 @@ using System.Net.Sockets; +using System.Text; using System.Windows; namespace ITNexusAgent.UI; @@ -6,13 +7,15 @@ namespace ITNexusAgent.UI; public partial class RdpConsentWindow : Window { private readonly int _port; + private readonly string _secret; private bool _answered = false; private System.Threading.CancellationTokenSource _countdownCts = new(); - public RdpConsentWindow(int port) + public RdpConsentWindow(int port, string secret) { InitializeComponent(); _port = port; + _secret = secret; _ = RunCountdownAsync(_countdownCts.Token); } @@ -53,8 +56,10 @@ public partial class RdpConsentWindow : Window { using var tcp = new TcpClient(); tcp.Connect("127.0.0.1", _port); - tcp.GetStream().WriteByte((byte)(accepted ? 1 : 0)); - tcp.GetStream().Flush(); + var stream = tcp.GetStream(); + stream.Write(Encoding.ASCII.GetBytes(_secret)); + stream.WriteByte((byte)(accepted ? 1 : 0)); + stream.Flush(); } catch { } } diff --git a/agent-cs/setup.iss b/agent-cs/setup.iss index 028f2f0..1ca0615 100644 --- a/agent-cs/setup.iss +++ b/agent-cs/setup.iss @@ -1,5 +1,5 @@ #define MyAppName "IT Nexus Agent" -#define MyAppVersion "2.6.0" +#define MyAppVersion "2.7.0" #define MyAppPublisher "Cereda Systems GmbH" #define MyAppURL "https://it-nexus.cereda-systems.de" #define MyAppExeName "IT-Nexus-Agent.exe" diff --git a/backend/package-lock.json b/backend/package-lock.json index 8846786..71817bb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 164e06c..7d5e135 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/config/jwt.js b/backend/src/config/jwt.js index 801b561..21d95f1 100644 --- a/backend/src/config/jwt.js +++ b/backend/src/config/jwt.js @@ -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; diff --git a/backend/src/controllers/ai.controller.js b/backend/src/controllers/ai.controller.js index beadf0c..e9a98da 100644 --- a/backend/src/controllers/ai.controller.js +++ b/backend/src/controllers/ai.controller.js @@ -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), diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 5f65dde..c583a8e 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -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' diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js index 582d09b..991dd56 100644 --- a/backend/src/db/seed.js +++ b/backend/src/db/seed.js @@ -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(); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 0b6b383..e23fee8 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -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') { diff --git a/backend/src/models/AuditLog.js b/backend/src/models/AuditLog.js index ee7bbd7..9ce1ab0 100644 --- a/backend/src/models/AuditLog.js +++ b/backend/src/models/AuditLog.js @@ -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 */ diff --git a/backend/src/models/FidoKey.js b/backend/src/models/FidoKey.js index d391a93..457c2ed 100644 --- a/backend/src/models/FidoKey.js +++ b/backend/src/models/FidoKey.js @@ -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); diff --git a/backend/src/routes/microsoft.auth.routes.js b/backend/src/routes/microsoft.auth.routes.js index 4f96cee..b42c36d 100644 --- a/backend/src/routes/microsoft.auth.routes.js +++ b/backend/src/routes/microsoft.auth.routes.js @@ -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; diff --git a/backend/src/routes/tv.routes.js b/backend/src/routes/tv.routes.js index 7c9f775..c44f154 100644 --- a/backend/src/routes/tv.routes.js +++ b/backend/src/routes/tv.routes.js @@ -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; diff --git a/backend/src/server.js b/backend/src/server.js index 933d357..07c3e36 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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'); diff --git a/backend/src/services/dataRetention.service.js b/backend/src/services/dataRetention.service.js new file mode 100644 index 0000000..cead228 --- /dev/null +++ b/backend/src/services/dataRetention.service.js @@ -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 }; diff --git a/backend/src/services/email.service.js b/backend/src/services/email.service.js index bde2567..193f3f3 100644 --- a/backend/src/services/email.service.js +++ b/backend/src/services/email.service.js @@ -431,9 +431,9 @@ async function sendTicketCreatedConfirmation(ticket) {

${infoCard([ - ['Ticket-Nr.', `${ticket.ticket_number}`], - ['Betreff', `${ticket.title}`], - ['Kategorie', `${ticket.category}`], + ['Ticket-Nr.', `${escHtml(ticket.ticket_number)}`], + ['Betreff', `${escHtml(ticket.title)}`], + ['Kategorie', `${escHtml(ticket.category)}`], ['Priorität', priorityBadge(ticket.priority)], ['Status', statusBadge(ticket.status)], ])} @@ -494,11 +494,11 @@ async function sendTicketAssignedNotification(ticket, assignedUser) {

${infoCard([ - ['Ticket-Nr.', `${ticket.ticket_number}`], - ['Betreff', `${ticket.title}`], - ['Kategorie', `${ticket.category}`], + ['Ticket-Nr.', `${escHtml(ticket.ticket_number)}`], + ['Betreff', `${escHtml(ticket.title)}`], + ['Kategorie', `${escHtml(ticket.category)}`], ['Priorität', priorityBadge(ticket.priority)], - ['Von', `${ticket.requester_name || 'Unbekannt'}${ticket.requester_email ? ` <${ticket.requester_email}>` : ''}`], + ['Von', `${escHtml(ticket.requester_name || 'Unbekannt')}${ticket.requester_email ? ` <${escHtml(ticket.requester_email)}>` : ''}`], ])}`; await sendMail( @@ -554,10 +554,10 @@ async function sendCommentNotification(ticket, comment) { @@ -567,14 +567,14 @@ async function sendCommentNotification(ticket, comment) {
- ${authorName.charAt(0).toUpperCase()} + ${escHtml(authorName.charAt(0).toUpperCase())} - ${authorName} + ${escHtml(authorName)} · IT Support
- ${comment.comment.replace(//g, '>')} + ${escHtml(comment.comment)}
${infoCard([ - ['Ticket-Nr.', `${ticket.ticket_number}`], - ['Betreff', `${ticket.title}`], + ['Ticket-Nr.', `${escHtml(ticket.ticket_number)}`], + ['Betreff', `${escHtml(ticket.title)}`], ['Status', statusBadge(ticket.status)], ])}`; @@ -611,19 +611,19 @@ async function sendStaffCommentNotification(ticket, comment) { const content = `

Neue Antwort im Ticket

- ${requesterName} hat auf Ticket ${ticket.ticket_number} geantwortet. + ${escHtml(requesterName)} hat auf Ticket ${escHtml(ticket.ticket_number)} geantwortet.

- ${requesterName} + ${escHtml(requesterName)}
- ${comment.comment.replace(//g, '>')} + ${escHtml(comment.comment)}
${infoCard([ - ['Ticket-Nr.', `${ticket.ticket_number}`], - ['Betreff', `${ticket.title}`], + ['Ticket-Nr.', `${escHtml(ticket.ticket_number)}`], + ['Betreff', `${escHtml(ticket.title)}`], ['Status', statusBadge(ticket.status)], ])}`; @@ -660,10 +660,10 @@ async function sendStaffTicketCreatedNotification(ticket) { const content = `

Neues Ticket eingegangen

${infoCard([ - ['Ticket-Nr.', `${ticket.ticket_number}`], - ['Betreff', `${ticket.title}`], - ['Von', `${ticket.requester_name || ''}${ticket.requester_email ? ` <${ticket.requester_email}>` : ''}`], - ['Kategorie', `${ticket.category || '—'}`], + ['Ticket-Nr.', `${escHtml(ticket.ticket_number)}`], + ['Betreff', `${escHtml(ticket.title)}`], + ['Von', `${escHtml(ticket.requester_name || '')}${ticket.requester_email ? ` <${escHtml(ticket.requester_email)}>` : ''}`], + ['Kategorie', `${escHtml(ticket.category || '—')}`], ['Priorität', priorityBadge(ticket.priority)], ])}`; @@ -735,8 +735,8 @@ async function sendStatusChangeNotification(ticket, oldStatus, newStatus) { ${statusChangeVisual} ${infoCard([ - ['Ticket-Nr.', `${ticket.ticket_number}`], - ['Betreff', `${ticket.title}`], + ['Ticket-Nr.', `${escHtml(ticket.ticket_number)}`], + ['Betreff', `${escHtml(ticket.title)}`], ['Priorität', priorityBadge(ticket.priority)], ])} @@ -1032,10 +1032,10 @@ async function sendEscalationEmail(ticket) { ${introHtml}

- - + + - +
Ticket: ${ticket.ticket_number}
Titel: ${ticket.title}
Ticket: ${escHtml(ticket.ticket_number)}
Titel: ${escHtml(ticket.title)}
Priorität: ${priorityBadge(ticket.priority)}
Ersteller: ${ticket.requester_name || ticket.requester_email || 'Unbekannt'}
Ersteller: ${escHtml(ticket.requester_name || ticket.requester_email || 'Unbekannt')}
Erstellt: ${new Date(ticket.created_at + 'Z').toLocaleString('de-DE')}
`; diff --git a/backend/src/utils/authCookie.js b/backend/src/utils/authCookie.js new file mode 100644 index 0000000..316b901 --- /dev/null +++ b/backend/src/utils/authCookie.js @@ -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 }; diff --git a/backend/src/utils/crypto.js b/backend/src/utils/crypto.js new file mode 100644 index 0000000..614e215 --- /dev/null +++ b/backend/src/utils/crypto.js @@ -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 }; diff --git a/backend/src/utils/ssrfGuard.js b/backend/src/utils/ssrfGuard.js new file mode 100644 index 0000000..50a103a --- /dev/null +++ b/backend/src/utils/ssrfGuard.js @@ -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 }; diff --git a/backend/src/ws/shellServer.js b/backend/src/ws/shellServer.js index 5a36665..cafefd1 100644 --- a/backend/src/ws/shellServer.js +++ b/backend/src/ws/shellServer.js @@ -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()); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 97b53e4..7dba970 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "axios": "^1.6.5", + "dompurify": "^3.4.11", "html5-qrcode": "^2.3.8", "marked": "^17.0.4", "react": "^18.2.0", @@ -3958,7 +3959,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/ws": { @@ -7074,6 +7075,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5d461aa..60ac310 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "private": true, "dependencies": { "axios": "^1.6.5", + "dompurify": "^3.4.11", "html5-qrcode": "^2.3.8", "marked": "^17.0.4", "react": "^18.2.0", diff --git a/frontend/public/share-tool.html b/frontend/public/share-tool.html index 3a9cb66..629f90f 100644 --- a/frontend/public/share-tool.html +++ b/frontend/public/share-tool.html @@ -1234,21 +1234,16 @@ button, input, textarea, select { font: inherit; color: inherit; } let shares = []; let demoMode = false; - /* ---------- Auth token ---------- */ - const TOKEN_KEY = "token"; - const getToken = () => localStorage.getItem(TOKEN_KEY); - const setToken = (t) => localStorage.setItem(TOKEN_KEY, t); - const clearToken = () => localStorage.removeItem(TOKEN_KEY); + /* ---------- Auth (httpOnly-Cookie, kein Token in localStorage/URL) ---------- */ + let demoModeFlag = false; /* ---------- Fetch helper ---------- */ async function api(path, opts = {}) { const headers = new Headers(opts.headers || {}); - const tk = getToken(); - if (tk) headers.set("Authorization", "Bearer " + tk); if (opts.body && !(opts.body instanceof FormData) && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } - const res = await fetch(path, { ...opts, headers }); + const res = await fetch(path, { ...opts, headers, credentials: "include" }); if (!res.ok) { const err = new Error("HTTP " + res.status); err.status = res.status; @@ -1259,19 +1254,6 @@ button, input, textarea, select { font: inherit; color: inherit; } return ct.includes("application/json") ? res.json() : res.text(); } - /* ---------- Capture ?token= from URL ---------- */ - function captureUrlToken() { - const params = new URLSearchParams(location.search); - const t = params.get("token"); - if (t) { - setToken(t); - params.delete("token"); - const qs = params.toString(); - const newUrl = location.pathname + (qs ? "?" + qs : "") + location.hash; - history.replaceState(null, "", newUrl); - } - } - /* ---------- Theme toggle ---------- */ (function initTheme() { const KEY = "cereda-theme"; @@ -1290,17 +1272,12 @@ button, input, textarea, select { font: inherit; color: inherit; } /* ---------- Init ---------- */ async function init() { - captureUrlToken(); - const tk = getToken(); - if (!tk) return showLogin(); - try { const me = await api("/api/auth/me"); showApp(me); await loadShares(); } catch (e) { if (e.status === 401 || e.status === 403) { - clearToken(); showLogin(); } else { enterDemoMode(); @@ -1311,7 +1288,7 @@ button, input, textarea, select { font: inherit; color: inherit; } /* ---------- Demo fallback ---------- */ function enterDemoMode() { demoMode = true; - setToken("demo-token"); + demoModeFlag = true; showApp({ username: "m.schmidt", display_name: "Marco Schmidt" }); demoBadge.classList.add("on"); shares = seedShares(); @@ -1391,12 +1368,11 @@ button, input, textarea, select { font: inherit; color: inherit; } method: "POST", body: JSON.stringify({ username, password }) }); - if (res && res.token) { - setToken(res.token); + if (res && res.status === "success") { const me = await api("/api/auth/me").catch(() => null); showApp(me || { username }); await loadShares(); - } else throw new Error("No token in response."); + } else throw new Error("Login fehlgeschlagen."); } catch (err) { if (err.status === 401 || err.status === 403) { // Real auth rejection — show the error @@ -1413,7 +1389,7 @@ button, input, textarea, select { font: inherit; color: inherit; } /* ---------- Logout ---------- */ logoutBtn.addEventListener("click", () => { - clearToken(); + if (!demoModeFlag) { api("/api/auth/logout", { method: "POST" }).catch(() => {}); } shares = []; pickedFile = null; demoMode = false; @@ -1593,7 +1569,7 @@ button, input, textarea, select { font: inherit; color: inherit; } const list = await api("/api/shares"); shares = Array.isArray(list) ? list : (list?.shares || []); } catch (e) { - if (e.status === 401) { clearToken(); showLogin(); return; } + if (e.status === 401) { showLogin(); return; } // 403 = not admin, just show empty list — user can still create shares shares = []; } diff --git a/frontend/src/components/common/AiChatWidget.jsx b/frontend/src/components/common/AiChatWidget.jsx index 792942f..00f554d 100644 --- a/frontend/src/components/common/AiChatWidget.jsx +++ b/frontend/src/components/common/AiChatWidget.jsx @@ -2,17 +2,7 @@ import React, { useState, useRef, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; import aiService from '../../services/aiService'; -import { marked } from 'marked'; - -marked.use({ breaks: true, gfm: true }); -const renderMd = (text) => { - try { - const html = marked.parse(String(text || ''), { async: false }); - return { __html: typeof html === 'string' ? html : String(html) }; - } catch { - return { __html: String(text || '').replace(/\n/g, '
') }; - } -}; +import { renderMd } from '../../utils/sanitizeMarkdown'; const STAFF_ROLES = ['super_admin', 'admin', 'support', 'bearbeiter']; diff --git a/frontend/src/components/common/RemoteDesktopPanel.jsx b/frontend/src/components/common/RemoteDesktopPanel.jsx index c7bc910..df2237c 100644 --- a/frontend/src/components/common/RemoteDesktopPanel.jsx +++ b/frontend/src/components/common/RemoteDesktopPanel.jsx @@ -20,9 +20,8 @@ export default function RemoteDesktopPanel({ agentId, agentHostname, autoConnect const connectedRef = useRef(false); const getWsUrl = () => { - const token = localStorage.getItem('token'); const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; - return `${proto}://${window.location.host}/ws?type=rdp&agentId=${agentId}&token=${token}`; + return `${proto}://${window.location.host}/ws?type=rdp&agentId=${agentId}`; }; const connect = (screen = screenIdx) => { @@ -33,6 +32,7 @@ export default function RemoteDesktopPanel({ agentId, agentHostname, autoConnect connectedRef.current = false; ws.onopen = () => { + ws.send(JSON.stringify({ type: 'auth', token: localStorage.getItem('token') })); ws.send(JSON.stringify({ type: 'rdp_start', screen })); }; diff --git a/frontend/src/pages/AgentDetailPage.jsx b/frontend/src/pages/AgentDetailPage.jsx index f6062b5..6390693 100644 --- a/frontend/src/pages/AgentDetailPage.jsx +++ b/frontend/src/pages/AgentDetailPage.jsx @@ -177,10 +177,9 @@ function RemoteShell({ agentId, agentHostname }) { const inputRef = useRef(null); const getWsUrl = () => { - const token = localStorage.getItem('token'); const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; const host = window.location.host; - return `${proto}://${host}/ws?type=shell&agentId=${agentId}&token=${token}`; + return `${proto}://${host}/ws?type=shell&agentId=${agentId}`; }; const connect = () => { @@ -191,7 +190,10 @@ function RemoteShell({ agentId, agentHostname }) { const ws = new WebSocket(getWsUrl()); wsRef.current = ws; - ws.onopen = () => setStatus('connected'); + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'auth', token: localStorage.getItem('token') })); + setStatus('connected'); + }; ws.onmessage = (e) => { setOutput(prev => prev + stripAnsi(e.data)); @@ -766,7 +768,7 @@ export default function AgentDetailPage() { {/* ── REMOTE TOOLS ─────────────────────────────────────────────────── */} - {(isSuperAdmin || isAdmin) && ( + {(isSuperAdmin() || isAdmin()) && (
{[ @@ -790,10 +792,6 @@ export default function AgentDetailPage() { {shellTab === 'rdp' && }
)} - {!isSuperAdmin && !isAdmin && ( - - )} - {/* ── ANKÜNDIGUNG MODAL ────────────────────────────────────────────── */} {showAnnModal && (
({ __html: marked.parse(text) }); +import { renderMd } from '../utils/sanitizeMarkdown'; const WELCOME_MSG = { role: 'assistant', diff --git a/frontend/src/pages/DocsPage.jsx b/frontend/src/pages/DocsPage.jsx index 5251a47..7b66520 100644 --- a/frontend/src/pages/DocsPage.jsx +++ b/frontend/src/pages/DocsPage.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { Marked } from 'marked'; +import { sanitizeHtml } from '../utils/sanitizeMarkdown'; const marked = new Marked({ breaks: true, gfm: true }); @@ -42,10 +43,11 @@ export default function DocsPage() { } // Add IDs to headings for anchor links const html = marked.parse(text); - return html.replace(/(.*?)<\/h\1>/g, (_, level, heading) => { + const withIds = html.replace(/(.*?)<\/h\1>/g, (_, level, heading) => { const id = slugify(heading.replace(/<[^>]+>/g, '')); return `${heading}`; }); + return sanitizeHtml(withIds); }; if (loading) return ( diff --git a/frontend/src/pages/FidoKeysPage.jsx b/frontend/src/pages/FidoKeysPage.jsx index bf6cb0d..c5aa31f 100644 --- a/frontend/src/pages/FidoKeysPage.jsx +++ b/frontend/src/pages/FidoKeysPage.jsx @@ -25,8 +25,17 @@ const FidoKeysPage = () => { serial_number: '', status: 'aktiv', description: '', + pin: '', assigned_to_user_id: '', }); + const [revealedPins, setRevealedPins] = useState({}); + + const togglePinReveal = (id) => { + setRevealedPins(prev => ({ ...prev, [id]: !prev[id] })); + if (!revealedPins[id]) { + setTimeout(() => setRevealedPins(prev => ({ ...prev, [id]: false })), 8000); + } + }; useEffect(() => { loadKeys(); @@ -69,7 +78,7 @@ const FidoKeysPage = () => { const handleCreate = () => { setEditingKey(null); - setFormData({ name: '', serial_number: '', status: 'aktiv', description: '', assigned_to_user_id: '' }); + setFormData({ name: '', serial_number: '', status: 'aktiv', description: '', pin: '', assigned_to_user_id: '' }); setShowModal(true); }; @@ -80,6 +89,7 @@ const FidoKeysPage = () => { serial_number: key.serial_number, status: key.status, description: key.description || '', + pin: key.pin || '', assigned_to_user_id: key.assigned_to_user_id || '', }); setShowModal(true); @@ -181,6 +191,7 @@ const FidoKeysPage = () => { Seriennummer Status Zugewiesen an + PIN Beschreibung Erstellt von Aktionen @@ -189,13 +200,20 @@ const FidoKeysPage = () => { {filteredKeys.length === 0 ? ( - Keine FIDO-Keys gefunden + Keine FIDO-Keys gefunden ) : ( filteredKeys.map((key) => ( - {key.name} - {key.serial_number} + +
+
+ +
+ {key.name} +
+ + {key.serial_number} {key.status} @@ -218,20 +236,50 @@ const FidoKeysPage = () => { — nicht zugewiesen )} + + {key.pin ? ( +
+ + {revealedPins[key.id] ? key.pin : '••••••'} + + +
+ ) : ( + — keine PIN + )} + {key.description || '-'} {key.created_by_username}
{canModifyFidoKeys() && ( <> - - + )} {isAdmin() && ( - + )}
@@ -261,6 +309,20 @@ const FidoKeysPage = () => { setFormData({ ...formData, serial_number: e.target.value })} required />
+
+ + setFormData({ ...formData, pin: e.target.value.replace(/\D/g, '').slice(0, 6) })} + style={{fontFamily:'Consolas,monospace',letterSpacing:'0.2em'}} + /> +
+
setEditForm({ ...editForm, name: e.target.value })} + placeholder="Name" + /> + setEditForm({ ...editForm, pin: e.target.value.replace(/\D/g,'').slice(0,6) })} + placeholder="PIN (6-stellig)" + /> + +
+ + +
-
-
{fmtTime(key.last_used_at)}
-
Letzte Nutzung
-
-
+ ) : ( + <> +
{key.name}
+
{key.manufacturer || 'Yubico'} · {key.connection_type || 'USB'} · SN {key.serial_number}
+ {key.pin && ( +
+ + {pinRevealed[key.id] ? key.pin : '••••••'} + + +
+ )} +
+
+
+
Auth. Gesamt
+
+
+
{fmtTime(key.last_used_at)}
+
Letzte Nutzung
+
+
+ + )} ))} {showAssign ? ( diff --git a/frontend/src/pages/UserPortalPage.jsx b/frontend/src/pages/UserPortalPage.jsx index 127bfb1..c5111a3 100644 --- a/frontend/src/pages/UserPortalPage.jsx +++ b/frontend/src/pages/UserPortalPage.jsx @@ -7,17 +7,7 @@ import fidoKeyService from '../services/fidoKeyService'; import aiService from '../services/aiService'; import LoadingSpinner from '../components/common/LoadingSpinner'; import { toast } from 'react-toastify'; -import { marked } from 'marked'; - -marked.use({ breaks: true, gfm: true }); -const renderMd = (text) => { - try { - const html = marked.parse(String(text || ''), { async: false }); - return { __html: typeof html === 'string' ? html : String(html) }; - } catch { - return { __html: String(text || '').replace(/\n/g, '
') }; - } -}; +import { renderMd } from '../utils/sanitizeMarkdown'; /* ── Helpers ────────────────────────────────────────────────────── */ diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index cee81e2..dadaf28 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -3,33 +3,16 @@ import axios from 'axios'; const API_URL = process.env.REACT_APP_API_URL || '/api'; // Create axios instance +// Auth läuft über ein httpOnly-Cookie (vom Server gesetzt) — kein Token in JS-lesbarem Storage, +// damit ein XSS-Treffer das Session-Token nicht einfach auslesen kann. const api = axios.create({ baseURL: API_URL, + withCredentials: true, headers: { 'Content-Type': 'application/json', }, }); -// Request interceptor to add token to requests -api.interceptors.request.use( - (config) => { - const token = localStorage.getItem('token'); - if (token) { - if (typeof config.headers?.set === 'function') { - config.headers.set('Authorization', `Bearer ${token}`); - } else if (config.headers) { - config.headers['Authorization'] = `Bearer ${token}`; - } else { - config.headers = { 'Authorization': `Bearer ${token}` }; - } - } - return config; - }, - (error) => { - return Promise.reject(error); - } -); - // Response interceptor to handle errors globally api.interceptors.response.use( (response) => { @@ -39,7 +22,6 @@ api.interceptors.response.use( if (error.response) { // Handle 401 Unauthorized - token expired or invalid if (error.response.status === 401) { - localStorage.removeItem('token'); localStorage.removeItem('user'); window.location.href = '/login'; } diff --git a/frontend/src/utils/sanitizeMarkdown.js b/frontend/src/utils/sanitizeMarkdown.js new file mode 100644 index 0000000..5f013ff --- /dev/null +++ b/frontend/src/utils/sanitizeMarkdown.js @@ -0,0 +1,20 @@ +import { Marked } from 'marked'; +import DOMPurify from 'dompurify'; + +const marked = new Marked({ breaks: true, gfm: true }); + +// Rendert Markdown zu HTML und entfernt anschließend aktive Inhalte (script, on*-Attribute, +// javascript:-URLs etc.) — verhindert Stored XSS über KI-Antworten/Kommentare/Knowledge-Base. +export function renderMd(text) { + try { + const html = marked.parse(String(text || ''), { async: false }); + const raw = typeof html === 'string' ? html : String(html); + return { __html: DOMPurify.sanitize(raw) }; + } catch { + return { __html: DOMPurify.sanitize(String(text || '').replace(/\n/g, '
')) }; + } +} + +export function sanitizeHtml(html) { + return DOMPurify.sanitize(String(html || '')); +} diff --git a/shell-client.js b/shell-client.js new file mode 100644 index 0000000..eb72d9a --- /dev/null +++ b/shell-client.js @@ -0,0 +1,26 @@ +const WebSocket = require('ws'); +const fs = require('fs'); + +const token = process.argv[2]; +const agentId = process.argv[3]; +const command = process.argv[4].startsWith('@') ? fs.readFileSync(process.argv[4].slice(1), 'utf8') : process.argv[4]; + +const ws = new WebSocket(`ws://localhost:5000/ws?type=shell&agentId=${agentId}`); + +let buffer = ''; +ws.on('open', () => { + ws.send(JSON.stringify({ type: 'auth', token })); + setTimeout(() => { + ws.send(command + '\r\n'); + }, 1500); +}); + +ws.on('message', (data) => { + buffer += data.toString(); +}); + +const waitMs = parseInt(process.argv[5]) || 8000; +setTimeout(() => { + console.log(buffer); + process.exit(0); +}, waitMs);