Security: WS-Rollenprüfung, JWT-Cookie statt localStorage, XSS/SSRF-Fixes, RDP-Consent-Secret
- WebSocket Shell/RDP: Rollenprüfung statt nur JWT-Gültigkeit (war: jeder eingeloggte User konnte fremde Agents per Shell/RDP übernehmen) - JWT_SECRET: Server bricht ab statt mit unsicherem Default weiterzulaufen - Auth: Token läuft jetzt über httpOnly-Cookie statt localStorage (XSS-Schutz gegen Session-Diebstahl) - WS-Auth: Token nicht mehr als URL-Query-Param (landete in nginx-Logs), sondern als erste Message bzw. automatisch via Cookie - Frontend: toter Rollen-Check (isSuperAdmin/isAdmin ohne Funktionsaufruf) in AgentDetailPage gefixt - XSS: DOMPurify-Sanitizing für alle marked.parse()-Renderstellen (KI-Antworten, Kommentare, Knowledge Base) - E-Mail: HTML-Escaping für alle ticket-gesteuerten Felder (auch über öffentliche Ticket-Route erreichbar) - SSRF-Schutz beim Knowledge-Base-URL-Import (blockt private/Loopback-Adressen) - TV-Dashboard: Shared-Key statt komplett offenem Endpoint - Striktes Rate-Limit auf /login, must_change_password serverseitig erzwungen - Agent (C#) v2.7.0: RDP-Consent/Disconnect/Capture verlangen jetzt ein Pro-Session-Secret (war: jeder lokale Prozess konnte Consent vortäuschen), DataDir-ACL für agent.log/status.json - FIDO-PINs AES-256-GCM-verschlüsselt statt Klartext, Retention-Job für alte patch_commands/audit_log Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
|
||||
@@ -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, '<br>') };
|
||||
}
|
||||
};
|
||||
import { renderMd } from '../../utils/sanitizeMarkdown';
|
||||
|
||||
const STAFF_ROLES = ['super_admin', 'admin', 'support', 'bearbeiter'];
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
};
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
{/* ── REMOTE TOOLS ─────────────────────────────────────────────────── */}
|
||||
{(isSuperAdmin || isAdmin) && (
|
||||
{(isSuperAdmin() || isAdmin()) && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 0, borderBottom: '1px solid var(--border-color)' }}>
|
||||
{[
|
||||
@@ -790,10 +792,6 @@ export default function AgentDetailPage() {
|
||||
{shellTab === 'rdp' && <RemoteDesktop agentId={agent.id} agentHostname={agent.hostname} />}
|
||||
</div>
|
||||
)}
|
||||
{!isSuperAdmin && !isAdmin && (
|
||||
<RemoteShell agentId={agent.id} agentHostname={agent.hostname} />
|
||||
)}
|
||||
|
||||
{/* ── ANKÜNDIGUNG MODAL ────────────────────────────────────────────── */}
|
||||
{showAnnModal && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
|
||||
@@ -2,10 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import aiService from '../services/aiService';
|
||||
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 WELCOME_MSG = {
|
||||
role: 'assistant',
|
||||
|
||||
@@ -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([23])>(.*?)<\/h\1>/g, (_, level, heading) => {
|
||||
const withIds = html.replace(/<h([23])>(.*?)<\/h\1>/g, (_, level, heading) => {
|
||||
const id = slugify(heading.replace(/<[^>]+>/g, ''));
|
||||
return `<h${level} id="${id}">${heading}</h${level}>`;
|
||||
});
|
||||
return sanitizeHtml(withIds);
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
|
||||
@@ -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 = () => {
|
||||
<th>Seriennummer</th>
|
||||
<th>Status</th>
|
||||
<th>Zugewiesen an</th>
|
||||
<th>PIN</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Erstellt von</th>
|
||||
<th>Aktionen</th>
|
||||
@@ -189,13 +200,20 @@ const FidoKeysPage = () => {
|
||||
<tbody>
|
||||
{filteredKeys.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="text-center">Keine FIDO-Keys gefunden</td>
|
||||
<td colSpan="8" className="text-center">Keine FIDO-Keys gefunden</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredKeys.map((key) => (
|
||||
<tr key={key.id}>
|
||||
<td>{key.name}</td>
|
||||
<td><code style={{fontSize:12}}>{key.serial_number}</code></td>
|
||||
<td>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10,fontWeight:600}}>
|
||||
<div style={{width:32,height:32,borderRadius:8,background:'rgba(63,163,163,0.12)',display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0}}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="var(--cereda-primary)" strokeWidth="2" width="16" height="16"><circle cx="8" cy="8" r="5"/><path d="M10.5 12.5 19 21M16 16l2-2M19 19l2-2"/></svg>
|
||||
</div>
|
||||
{key.name}
|
||||
</div>
|
||||
</td>
|
||||
<td><span style={{fontFamily:'Consolas,monospace',background:'var(--bg-tertiary)',border:'1px solid var(--border-color)',borderRadius:6,padding:'3px 8px',fontSize:12,color:'var(--text-muted)'}}>{key.serial_number}</span></td>
|
||||
<td>
|
||||
<span className={`status-badge status-${key.status}`}>{key.status}</span>
|
||||
</td>
|
||||
@@ -218,20 +236,50 @@ const FidoKeysPage = () => {
|
||||
<span style={{color:'var(--text-muted)',fontSize:12}}>— nicht zugewiesen</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{key.pin ? (
|
||||
<div style={{display:'flex',alignItems:'center',gap:8}}>
|
||||
<span style={{fontFamily:'Consolas,monospace',background:'var(--bg-tertiary)',border:'1px solid var(--border-color)',borderRadius:6,padding:'3px 10px',fontSize:13,letterSpacing:'0.15em',minWidth:64,textAlign:'center',display:'inline-block'}}>
|
||||
{revealedPins[key.id] ? key.pin : '••••••'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => 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] ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-7-11-7a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 7 11 7a18.5 18.5 0 0 1-2.16 3.19M14.12 14.12a3 3 0 1 1-4.24-4.24"/><path d="M1 1l22 22"/></svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{color:'var(--text-muted)',fontSize:12}}>— keine PIN</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{key.description || '-'}</td>
|
||||
<td>{key.created_by_username}</td>
|
||||
<td>
|
||||
<div className="table-actions">
|
||||
{canModifyFidoKeys() && (
|
||||
<>
|
||||
<button onClick={() => handleEdit(key)} className="btn btn-primary btn-small">Bearbeiten</button>
|
||||
<button onClick={() => handleStatusToggle(key)} className="btn btn-secondary btn-small">
|
||||
{key.status === 'aktiv' ? 'Deaktivieren' : 'Aktivieren'}
|
||||
<button onClick={() => handleEdit(key)} title="Bearbeiten" className="btn btn-secondary btn-small" style={{width:32,padding:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="14" height="14"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>
|
||||
</button>
|
||||
<button onClick={() => 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' ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="14" height="14"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="14" height="14"><path d="M5 3l16 9-16 9V3z"/></svg>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isAdmin() && (
|
||||
<button onClick={() => handleDelete(key.id)} className="btn btn-danger btn-small">Löschen</button>
|
||||
<button onClick={() => handleDelete(key.id)} title="Löschen" className="btn btn-danger btn-small" style={{width:32,padding:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="14" height="14"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0-1 14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2L4 6"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -261,6 +309,20 @@ const FidoKeysPage = () => {
|
||||
<input type="text" className="form-input" value={formData.serial_number} onChange={(e) => setFormData({ ...formData, serial_number: e.target.value })} required />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">PIN</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
maxLength={6}
|
||||
pattern="[0-9]{6}"
|
||||
placeholder="6-stellige PIN"
|
||||
value={formData.pin}
|
||||
onChange={(e) => setFormData({ ...formData, pin: e.target.value.replace(/\D/g, '').slice(0, 6) })}
|
||||
style={{fontFamily:'Consolas,monospace',letterSpacing:'0.2em'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Status*</label>
|
||||
<select className="form-select" value={formData.status} onChange={(e) => setFormData({ ...formData, status: e.target.value })} required>
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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, '<br>') };
|
||||
}
|
||||
};
|
||||
import { renderMd, sanitizeHtml } from '../utils/sanitizeMarkdown';
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
offen: { label: 'Offen', css: 'status-pending' },
|
||||
@@ -549,7 +539,7 @@ const TicketDetailPage = () => {
|
||||
</div>
|
||||
);
|
||||
const boldLine = line.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
return <div key={i} style={{ minHeight: line ? undefined : '0.5em' }} dangerouslySetInnerHTML={{ __html: boldLine || ' ' }} />;
|
||||
return <div key={i} style={{ minHeight: line ? undefined : '0.5em' }} dangerouslySetInnerHTML={{ __html: sanitizeHtml(boldLine || ' ') }} />;
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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 <div style={{padding:40,textAlign:'center',color:'var(--text-muted)'}}>Lade FIDO-Keys…</div>;
|
||||
|
||||
const hasEnoughKeys = keys.length >= 2;
|
||||
@@ -760,27 +786,80 @@ const FidoTab = ({ user }) => {
|
||||
<div className="bv-fido-hero">
|
||||
{keys.map((key, i) => (
|
||||
<div key={key.id} className="bv-fkc-card" style={{position:'relative'}}>
|
||||
<button
|
||||
onClick={() => 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</button>
|
||||
<div style={{position:'absolute',top:8,right:8,display:'flex',gap:6}}>
|
||||
<button
|
||||
onClick={() => 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</button>
|
||||
<button
|
||||
onClick={() => 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</button>
|
||||
</div>
|
||||
<div className="bv-fkc-header">
|
||||
<div className="bv-fkc-visual"><KeyIcon /></div>
|
||||
<span className="bv-fkc-type-badge">{i === 0 ? 'PRIMÄR' : 'BACKUP'}</span>
|
||||
</div>
|
||||
<div className="bv-fkc-name">{key.name}</div>
|
||||
<div className="bv-fkc-sub">{key.manufacturer || 'Yubico'} · {key.connection_type || 'USB'} · SN {key.serial_number}</div>
|
||||
<div className="bv-fkc-stats">
|
||||
<div className="bv-fkc-stat">
|
||||
<div className="bv-fkcs-v">—</div>
|
||||
<div className="bv-fkcs-l">Auth. Gesamt</div>
|
||||
|
||||
{editingId === key.id ? (
|
||||
<div style={{display:'flex',flexDirection:'column',gap:8,marginTop:4}}>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{fontSize:13}}
|
||||
value={editForm.name}
|
||||
onChange={e => setEditForm({ ...editForm, name: e.target.value })}
|
||||
placeholder="Name"
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{fontSize:13,fontFamily:'Consolas,monospace',letterSpacing:'0.15em'}}
|
||||
value={editForm.pin}
|
||||
maxLength={6}
|
||||
onChange={e => setEditForm({ ...editForm, pin: e.target.value.replace(/\D/g,'').slice(0,6) })}
|
||||
placeholder="PIN (6-stellig)"
|
||||
/>
|
||||
<select
|
||||
className="form-select"
|
||||
style={{fontSize:13}}
|
||||
value={editForm.status}
|
||||
onChange={e => setEditForm({ ...editForm, status: e.target.value })}
|
||||
>
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="inaktiv">Inaktiv</option>
|
||||
</select>
|
||||
<div style={{display:'flex',gap:8}}>
|
||||
<button className="btn btn-primary btn-small" onClick={() => saveEdit(key)} disabled={saving}>{saving ? '…' : 'Speichern'}</button>
|
||||
<button className="btn btn-secondary btn-small" onClick={() => setEditingId(null)}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bv-fkc-stat">
|
||||
<div className="bv-fkcs-v">{fmtTime(key.last_used_at)}</div>
|
||||
<div className="bv-fkcs-l">Letzte Nutzung</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bv-fkc-name">{key.name}</div>
|
||||
<div className="bv-fkc-sub">{key.manufacturer || 'Yubico'} · {key.connection_type || 'USB'} · SN {key.serial_number}</div>
|
||||
{key.pin && (
|
||||
<div style={{display:'flex',alignItems:'center',gap:6,marginTop:6}}>
|
||||
<span style={{fontFamily:'Consolas,monospace',background:'rgba(0,0,0,.2)',borderRadius:6,padding:'2px 8px',fontSize:12,letterSpacing:'0.15em'}}>
|
||||
{pinRevealed[key.id] ? key.pin : '••••••'}
|
||||
</span>
|
||||
<button onClick={() => togglePin(key.id)} title="PIN anzeigen/verbergen" style={{background:'none',border:'none',cursor:'pointer',color:'inherit',opacity:0.7,padding:0,display:'flex'}}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="13" height="13"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="bv-fkc-stats">
|
||||
<div className="bv-fkc-stat">
|
||||
<div className="bv-fkcs-v">—</div>
|
||||
<div className="bv-fkcs-l">Auth. Gesamt</div>
|
||||
</div>
|
||||
<div className="bv-fkc-stat">
|
||||
<div className="bv-fkcs-v">{fmtTime(key.last_used_at)}</div>
|
||||
<div className="bv-fkcs-l">Letzte Nutzung</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{showAssign ? (
|
||||
|
||||
@@ -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, '<br>') };
|
||||
}
|
||||
};
|
||||
import { renderMd } from '../utils/sanitizeMarkdown';
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────── */
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
20
frontend/src/utils/sanitizeMarkdown.js
Normal file
20
frontend/src/utils/sanitizeMarkdown.js
Normal file
@@ -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, '<br>')) };
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeHtml(html) {
|
||||
return DOMPurify.sanitize(String(html || ''));
|
||||
}
|
||||
Reference in New Issue
Block a user