From 81b1c326fc8126f53ff17723b6fc23c7acfc0bc4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Simon=20Gr=C3=BCssing?=
Date: Fri, 26 Jun 2026 13:27:17 +0200
Subject: [PATCH] =?UTF-8?q?Security:=20WS-Rollenpr=C3=BCfung,=20JWT-Cookie?=
=?UTF-8?q?=20statt=20localStorage,=20XSS/SSRF-Fixes,=20RDP-Consent-Secret?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
.gitignore | 1 +
agent-cs/AgentWorker.cs | 27 ++++-
agent-cs/CaptureModeRunner.cs | 3 +-
agent-cs/ConsentModeRunner.cs | 4 +-
agent-cs/IT-Nexus-Agent.csproj | 4 +-
agent-cs/IndicatorModeRunner.cs | 4 +-
agent-cs/Program.cs | 11 +-
agent-cs/Services/RtcService.cs | 75 +++++++++---
agent-cs/UI/RdpActiveIndicatorWindow.xaml.cs | 11 +-
agent-cs/UI/RdpConsentWindow.xaml.cs | 11 +-
agent-cs/setup.iss | 2 +-
backend/package-lock.json | 20 ++++
backend/package.json | 1 +
backend/src/config/jwt.js | 12 +-
backend/src/controllers/ai.controller.js | 16 ++-
backend/src/controllers/auth.controller.js | 7 +-
backend/src/db/seed.js | 18 +++
backend/src/middleware/auth.js | 16 +++
backend/src/models/AuditLog.js | 10 ++
backend/src/models/FidoKey.js | 24 +++-
backend/src/routes/microsoft.auth.routes.js | 1 +
backend/src/routes/tv.routes.js | 12 +-
backend/src/server.js | 17 +++
backend/src/services/dataRetention.service.js | 26 ++++
backend/src/services/email.service.js | 52 ++++----
backend/src/utils/authCookie.js | 20 ++++
backend/src/utils/crypto.js | 34 ++++++
backend/src/utils/ssrfGuard.js | 47 ++++++++
backend/src/ws/shellServer.js | 69 +++++++----
frontend/package-lock.json | 12 +-
frontend/package.json | 1 +
frontend/public/share-tool.html | 40 ++-----
.../src/components/common/AiChatWidget.jsx | 12 +-
.../components/common/RemoteDesktopPanel.jsx | 4 +-
frontend/src/pages/AgentDetailPage.jsx | 14 +--
frontend/src/pages/AiPage.jsx | 5 +-
frontend/src/pages/DocsPage.jsx | 4 +-
frontend/src/pages/FidoKeysPage.jsx | 78 ++++++++++--
frontend/src/pages/KnowledgeBasePage.jsx | 4 +-
frontend/src/pages/PatchManagementPage.jsx | 2 +-
frontend/src/pages/TVDashboardPage.jsx | 3 +-
frontend/src/pages/TicketDetailPage.jsx | 14 +--
frontend/src/pages/UserManagementPage.jsx | 111 +++++++++++++++---
frontend/src/pages/UserPortalPage.jsx | 12 +-
frontend/src/services/api.js | 24 +---
frontend/src/utils/sanitizeMarkdown.js | 20 ++++
shell-client.js | 26 ++++
47 files changed, 706 insertions(+), 235 deletions(-)
create mode 100644 backend/src/services/dataRetention.service.js
create mode 100644 backend/src/utils/authCookie.js
create mode 100644 backend/src/utils/crypto.js
create mode 100644 backend/src/utils/ssrfGuard.js
create mode 100644 frontend/src/utils/sanitizeMarkdown.js
create mode 100644 shell-client.js
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) {
- ${authorName.charAt(0).toUpperCase()}
+ ${escHtml(authorName.charAt(0).toUpperCase())}
- ${authorName}
+ ${escHtml(authorName)}
· IT Support
@@ -567,14 +567,14 @@ async function sendCommentNotification(ticket, comment) {
- ${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.serial_number}
{key.status}
@@ -218,20 +236,50 @@ const FidoKeysPage = () => {
— nicht zugewiesen
)}
+
+ {key.pin ? (
+
+
+ {revealedPins[key.id] ? key.pin : '••••••'}
+
+
togglePinReveal(key.id)}
+ title={revealedPins[key.id] ? 'PIN verbergen' : 'PIN anzeigen'}
+ style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-muted)',padding:2,display:'flex',alignItems:'center'}}
+ >
+ {revealedPins[key.id] ? (
+
+ ) : (
+
+ )}
+
+
+ ) : (
+ — keine PIN
+ )}
+
{key.description || '-'}
{key.created_by_username}
{canModifyFidoKeys() && (
<>
-
handleEdit(key)} className="btn btn-primary btn-small">Bearbeiten
-
handleStatusToggle(key)} className="btn btn-secondary btn-small">
- {key.status === 'aktiv' ? 'Deaktivieren' : 'Aktivieren'}
+ handleEdit(key)} title="Bearbeiten" className="btn btn-secondary btn-small" style={{width:32,padding:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
+
+
+ handleStatusToggle(key)} title={key.status === 'aktiv' ? 'Deaktivieren' : 'Aktivieren'} className="btn btn-secondary btn-small" style={{width:32,padding:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
+ {key.status === 'aktiv' ? (
+
+ ) : (
+
+ )}
>
)}
{isAdmin() && (
- handleDelete(key.id)} className="btn btn-danger btn-small">Löschen
+ handleDelete(key.id)} title="Löschen" className="btn btn-danger btn-small" style={{width:32,padding:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
+
+
)}
@@ -261,6 +309,20 @@ const FidoKeysPage = () => {
setFormData({ ...formData, serial_number: e.target.value })} required />
+
+ PIN
+ setFormData({ ...formData, pin: e.target.value.replace(/\D/g, '').slice(0, 6) })}
+ style={{fontFamily:'Consolas,monospace',letterSpacing:'0.2em'}}
+ />
+
+
Status*
setFormData({ ...formData, status: e.target.value })} required>
diff --git a/frontend/src/pages/KnowledgeBasePage.jsx b/frontend/src/pages/KnowledgeBasePage.jsx
index b1d96c3..a179b8e 100644
--- a/frontend/src/pages/KnowledgeBasePage.jsx
+++ b/frontend/src/pages/KnowledgeBasePage.jsx
@@ -4,9 +4,7 @@ import { useAuth } from '../context/AuthContext';
import aiService from '../services/aiService';
import ticketService from '../services/ticketService';
import { toast } from 'react-toastify';
-import { Marked } from 'marked';
-const marked = new Marked({ breaks: true, gfm: true });
-const renderMd = (text) => ({ __html: marked.parse(text || '') });
+import { renderMd } from '../utils/sanitizeMarkdown';
const CATEGORIES = ['Allgemein', 'Software', 'Hardware', 'Netzwerk', 'SelectLine', 'Sonstiges'];
const CAT_COLOR = { Software: '#6366f1', Hardware: '#f59e0b', SelectLine: '#10b981', Netzwerk: '#3b82f6', Allgemein: '#64748b', Sonstiges: '#8b5cf6' };
diff --git a/frontend/src/pages/PatchManagementPage.jsx b/frontend/src/pages/PatchManagementPage.jsx
index dd36158..0532e34 100644
--- a/frontend/src/pages/PatchManagementPage.jsx
+++ b/frontend/src/pages/PatchManagementPage.jsx
@@ -5,7 +5,7 @@ import { useAuth } from '../context/AuthContext';
const ANN_TYPES = { maintenance: { icon: '🔧', label: 'Wartung', color: '#f59e0b' }, warning: { icon: '⚠️', label: 'Warnung', color: '#ef4444' }, info: { icon: 'ℹ️', label: 'Info', color: '#6366f1' } };
-const LATEST_AGENT_VERSION = '2.6.0';
+const LATEST_AGENT_VERSION = '2.7.0';
const SEVERITY_LABELS = { critical: 'Kritisch', important: 'Wichtig', moderate: 'Moderat', low: 'Niedrig', all: 'Alle' };
const SEVERITY_COLORS = { critical: '#EF4444', important: '#F59E0B', moderate: '#3B82F6', low: '#6B7280', all: '#0D9488' };
const COMMAND_LABELS = { check_updates: '🔍 Update-Scan', install_updates: '⬇️ Installation', reboot: '🔄 Neustart', upgrade_win11: '🪟 Win 11 Upgrade', update_agent: '⬆️ Agent-Update' };
diff --git a/frontend/src/pages/TVDashboardPage.jsx b/frontend/src/pages/TVDashboardPage.jsx
index 7f36d0b..7ae1a6a 100644
--- a/frontend/src/pages/TVDashboardPage.jsx
+++ b/frontend/src/pages/TVDashboardPage.jsx
@@ -1,7 +1,8 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
const API = process.env.REACT_APP_API_URL || '/api';
-const apiFetch = (url) => fetch(url).then(r => r.ok ? r.json() : null).catch(() => null);
+const TV_KEY = new URLSearchParams(window.location.search).get('key') || '';
+const apiFetch = (url) => fetch(`${url}${url.includes('?') ? '&' : '?'}key=${encodeURIComponent(TV_KEY)}`).then(r => r.ok ? r.json() : null).catch(() => null);
/* ── Design tokens ──────────────────────────────────────────── */
const LIME = '#7CF53E';
diff --git a/frontend/src/pages/TicketDetailPage.jsx b/frontend/src/pages/TicketDetailPage.jsx
index 1c84920..117c506 100644
--- a/frontend/src/pages/TicketDetailPage.jsx
+++ b/frontend/src/pages/TicketDetailPage.jsx
@@ -6,17 +6,7 @@ import userService from '../services/userService';
import assetService from '../services/assetService';
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, sanitizeHtml } from '../utils/sanitizeMarkdown';
const STATUS_CONFIG = {
offen: { label: 'Offen', css: 'status-pending' },
@@ -549,7 +539,7 @@ const TicketDetailPage = () => {
);
const boldLine = line.replace(/\*\*(.+?)\*\*/g, '$1 ');
- return
;
+ return
;
})}
) : (
diff --git a/frontend/src/pages/UserManagementPage.jsx b/frontend/src/pages/UserManagementPage.jsx
index ac70531..5387254 100644
--- a/frontend/src/pages/UserManagementPage.jsx
+++ b/frontend/src/pages/UserManagementPage.jsx
@@ -712,6 +712,9 @@ const FidoTab = ({ user }) => {
const [showAssign, setShowAssign] = useState(false);
const [assignId, setAssignId] = useState('');
const [saving, setSaving] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [editForm, setEditForm] = useState({ name: '', pin: '', status: 'aktiv' });
+ const [pinRevealed, setPinRevealed] = useState({});
const loadKeys = () => {
setLoading(true);
@@ -751,6 +754,29 @@ const FidoTab = ({ user }) => {
loadKeys();
};
+ const startEdit = (key) => {
+ setEditingId(key.id);
+ setEditForm({ name: key.name, pin: key.pin || '', status: key.status });
+ };
+
+ const saveEdit = async (key) => {
+ setSaving(true);
+ try {
+ await authFetch(`${API}/fido-keys/${key.id}`, {
+ method: 'PUT',
+ body: JSON.stringify({ ...key, ...editForm }),
+ });
+ setEditingId(null);
+ loadKeys();
+ } catch {
+ } finally { setSaving(false); }
+ };
+
+ const togglePin = (id) => {
+ setPinRevealed(prev => ({ ...prev, [id]: !prev[id] }));
+ if (!pinRevealed[id]) setTimeout(() => setPinRevealed(prev => ({ ...prev, [id]: false })), 8000);
+ };
+
if (loading) return Lade FIDO-Keys…
;
const hasEnoughKeys = keys.length >= 2;
@@ -760,27 +786,80 @@ const FidoTab = ({ user }) => {
{keys.map((key, i) => (
-
handleUnassign(key)}
- title="Zuweisung aufheben"
- style={{position:'absolute',top:8,right:8,background:'rgba(239,68,68,.12)',border:'1px solid rgba(239,68,68,.3)',color:'#ef4444',borderRadius:6,padding:'2px 7px',fontSize:11,cursor:'pointer'}}
- >✕ Entfernen
+
+ startEdit(key)}
+ title="Bearbeiten"
+ style={{background:'rgba(63,163,163,.12)',border:'1px solid rgba(63,163,163,.3)',color:'var(--cereda-primary)',borderRadius:6,padding:'2px 7px',fontSize:11,cursor:'pointer'}}
+ >✎ Bearbeiten
+ handleUnassign(key)}
+ title="Zuweisung aufheben"
+ style={{background:'rgba(239,68,68,.12)',border:'1px solid rgba(239,68,68,.3)',color:'#ef4444',borderRadius:6,padding:'2px 7px',fontSize:11,cursor:'pointer'}}
+ >✕ Entfernen
+
{i === 0 ? 'PRIMÄR' : 'BACKUP'}
-
{key.name}
-
{key.manufacturer || 'Yubico'} · {key.connection_type || 'USB'} · SN {key.serial_number}
-
-
-
—
-
Auth. Gesamt
+
+ {editingId === key.id ? (
+
-
-
{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 : '••••••'}
+
+
togglePin(key.id)} title="PIN anzeigen/verbergen" style={{background:'none',border:'none',cursor:'pointer',color:'inherit',opacity:0.7,padding:0,display:'flex'}}>
+
+
+
+ )}
+
+
+
+
{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);