This commit is contained in:
2026-08-20 09:39:20 +02:00
parent 13547fb083
commit 7108d446d8
8 changed files with 137 additions and 16 deletions

View File

@ -57,6 +57,12 @@ button:hover { filter: brightness(1.1); }
.qr { display: block; margin: 1rem auto; border-radius: 6px; } .qr { display: block; margin: 1rem auto; border-radius: 6px; }
.recovery-codes { font-family: monospace; background: #0f1218; padding: 0.75rem; border-radius: 6px; } .recovery-codes { font-family: monospace; background: #0f1218; padding: 0.75rem; border-radius: 6px; }
/* Sichtbarkeits-Toggle ohne Inline-Styles -- CSP (style-src 'self', kein
'unsafe-inline') blockt sowohl style="..."-Attribute in Markup als auch
per JS gesetzte element.style-Aenderungen. Toggle daher ausschliesslich
ueber diese Klasse (siehe login.js / terminal.js / rdp.js). */
.hidden { display: none !important; }
.topbar { .topbar {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border); background: var(--panel); padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border); background: var(--panel);

View File

@ -41,14 +41,14 @@
const result = await postJson("/auth/login", { username, password }); const result = await postJson("/auth/login", { username, password });
pendingToken = result.pending_token; pendingToken = result.pending_token;
passwordFields.style.display = "none"; passwordFields.classList.add("hidden");
totpFields.style.display = "block"; totpFields.classList.remove("hidden");
if (!result.totp_enrolled) { if (!result.totp_enrolled) {
mode = "enroll_start"; mode = "enroll_start";
const enroll = await postJson("/auth/totp/enroll/start", { pending_token: pendingToken }); const enroll = await postJson("/auth/totp/enroll/start", { pending_token: pendingToken });
document.getElementById("qr-img").src = "data:image/png;base64," + enroll.qr_png_base64; document.getElementById("qr-img").src = "data:image/png;base64," + enroll.qr_png_base64;
enrollBox.style.display = "block"; enrollBox.classList.remove("hidden");
mode = "enroll_confirm"; mode = "enroll_confirm";
submitBtn.textContent = "TOTP bestaetigen & einrichten"; submitBtn.textContent = "TOTP bestaetigen & einrichten";
} else { } else {
@ -68,7 +68,7 @@
if (mode === "enroll_confirm") { if (mode === "enroll_confirm") {
const code = document.getElementById("totp-code").value.trim(); const code = document.getElementById("totp-code").value.trim();
const result = await postJson("/auth/totp/enroll/confirm", { pending_token: pendingToken, code }); const result = await postJson("/auth/totp/enroll/confirm", { pending_token: pendingToken, code });
recoveryBox.style.display = "block"; recoveryBox.classList.remove("hidden");
document.getElementById("recovery-codes").textContent = result.recovery_codes.join("\n"); document.getElementById("recovery-codes").textContent = result.recovery_codes.join("\n");
submitBtn.textContent = "Weiter zum Dashboard"; submitBtn.textContent = "Weiter zum Dashboard";
mode = "done"; mode = "done";

View File

@ -11,9 +11,9 @@
(() => { (() => {
"use strict"; "use strict";
const hostId = window.JUMPHOST_HOST_ID;
const statusEl = document.getElementById("status");
const shell = document.getElementById("session-shell"); const shell = document.getElementById("session-shell");
const hostId = shell.dataset.hostId;
const statusEl = document.getElementById("status");
const displayDiv = document.getElementById("rdp-display"); const displayDiv = document.getElementById("rdp-display");
const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const proto = window.location.protocol === "https:" ? "wss:" : "ws:";

View File

@ -9,9 +9,9 @@
(() => { (() => {
"use strict"; "use strict";
const hostId = window.JUMPHOST_HOST_ID;
const statusEl = document.getElementById("status");
const shell = document.getElementById("session-shell"); const shell = document.getElementById("session-shell");
const hostId = shell.dataset.hostId;
const statusEl = document.getElementById("status");
const term = new Terminal({ const term = new Terminal({
cursorBlink: true, cursorBlink: true,

View File

@ -19,18 +19,18 @@
<input type="password" id="password" autocomplete="current-password" required> <input type="password" id="password" autocomplete="current-password" required>
</div> </div>
<div id="totp-fields" style="display:none"> <div id="totp-fields" class="hidden">
<label for="totp-code">Code aus der Authenticator-App</label> <label for="totp-code">Code aus der Authenticator-App</label>
<input type="text" id="totp-code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code"> <input type="text" id="totp-code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code">
<p class="hint">Bei erstmaliger Anmeldung: Secret unten scannen, danach den 6-stelligen Code eingeben.</p> <p class="hint">Bei erstmaliger Anmeldung: Secret unten scannen, danach den 6-stelligen Code eingeben.</p>
</div> </div>
<div id="enroll-box" style="display:none"> <div id="enroll-box" class="hidden">
<img id="qr-img" class="qr" alt="TOTP QR-Code" width="180" height="180"> <img id="qr-img" class="qr" alt="TOTP QR-Code" width="180" height="180">
<p class="hint">Mit einer Authenticator-App (z.B. Aegis, FreeOTP) scannen.</p> <p class="hint">Mit einer Authenticator-App (z.B. Aegis, FreeOTP) scannen.</p>
</div> </div>
<div id="recovery-box" style="display:none"> <div id="recovery-box" class="hidden">
<p class="hint">Recovery-Codes -- jetzt sicher speichern, werden nicht erneut angezeigt:</p> <p class="hint">Recovery-Codes -- jetzt sicher speichern, werden nicht erneut angezeigt:</p>
<div id="recovery-codes" class="recovery-codes"></div> <div id="recovery-codes" class="recovery-codes"></div>
</div> </div>

View File

@ -7,7 +7,7 @@
<link rel="stylesheet" href="/static/css/app.css"> <link rel="stylesheet" href="/static/css/app.css">
</head> </head>
<body> <body>
<div class="session-shell" id="session-shell"> <div class="session-shell" id="session-shell" data-host-id="{{ host_id }}">
<div class="session-toolbar"> <div class="session-toolbar">
<button id="fullscreen-btn">Vollbild</button> <button id="fullscreen-btn">Vollbild</button>
<span class="spacer"></span> <span class="spacer"></span>
@ -17,7 +17,6 @@
</div> </div>
<script src="/static/js/vendor/guacamole-common.js"></script> <script src="/static/js/vendor/guacamole-common.js"></script>
<script>window.JUMPHOST_HOST_ID = {{ host_id }};</script>
<script src="/static/js/rdp.js"></script> <script src="/static/js/rdp.js"></script>
</body> </body>
</html> </html>

View File

@ -8,11 +8,11 @@
<link rel="stylesheet" href="/static/js/vendor/xterm.css"> <link rel="stylesheet" href="/static/js/vendor/xterm.css">
</head> </head>
<body> <body>
<div class="session-shell" id="session-shell"> <div class="session-shell" id="session-shell" data-host-id="{{ host_id }}">
<div class="session-toolbar"> <div class="session-toolbar">
<button id="fullscreen-btn">Vollbild</button> <button id="fullscreen-btn">Vollbild</button>
<button id="upload-btn">Datei hochladen</button> <button id="upload-btn">Datei hochladen</button>
<input type="file" id="file-input" style="display:none"> <input type="file" id="file-input" class="hidden">
<span class="spacer"></span> <span class="spacer"></span>
<span class="status" id="status">Verbinde ...</span> <span class="status" id="status">Verbinde ...</span>
</div> </div>
@ -21,7 +21,6 @@
<script src="/static/js/vendor/xterm.js"></script> <script src="/static/js/vendor/xterm.js"></script>
<script src="/static/js/vendor/xterm-addon-fit.js"></script> <script src="/static/js/vendor/xterm-addon-fit.js"></script>
<script>window.JUMPHOST_HOST_ID = {{ host_id }};</script>
<script src="/static/js/terminal.js"></script> <script src="/static/js/terminal.js"></script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,117 @@
"""
Regressionstests fuer die serverseitig gesetzte Content-Security-Policy
(app/main.py: script-src 'self'; style-src 'self'; kein 'unsafe-inline',
kein Nonce/Hash).
Hintergrund/Bug: templates/login.html, templates/terminal.html und
templates/rdp.html enthielten frueher Inline-style="..."-Attribute sowie
ein Inline-<script>window.JUMPHOST_HOST_ID = ...;</script>-Snippet. Chrome
(und andere CSP-konforme Browser) blockieren *jede* Aenderung des
style-Attributs -- auch per element.style.xyz = ... aus externem JS
gesetzte -- sowie jedes <script>-Element ohne src, wenn keine
'unsafe-inline'/Nonce/Hash-Ausnahme in der Policy steht. In der Praxis
fuehrte das dazu, dass:
* auf der Login-Seite die eigentlich per CSS versteckten Bereiche
(TOTP-Feld, QR-Code-Box, Recovery-Codes-Box) von Anfang an sichtbar
waren, inkl. eines kaputten <img>-Platzhalters fuer den TOTP-QR-Code
(das vom User gemeldete "QR-Code geht nicht"),
* auf den Terminal-/RDP-Session-Seiten window.JUMPHOST_HOST_ID nie
gesetzt wurde, wodurch der WebSocket-Tunnel auf eine falsche URL
(.../ws/ssh/undefined) verbunden hat.
Fix: Sichtbarkeit ausschliesslich ueber die CSS-Klasse `.hidden`
(static/css/app.css) toggeln statt ueber Inline-Styles, und host_id ueber
ein data-host-id-Attribut statt ueber ein Inline-<script> in die Seite
einschleusen (siehe login.js/terminal.js/rdp.js).
Diese Tests rendern die echten Seiten ueber die laufende App (nicht nur
die Rohdateien), damit auch von Jinja erzeugtes HTML erfasst wird, und
stellen sicher, dass kein zukuenftiger Rueckfall in Inline-Styles/-Scripts
unbemerkt bleibt.
"""
from __future__ import annotations
import re
import pytest
# Erfasst jedes style="..."- oder style='...'-Attribut in beliebigem Markup.
_INLINE_STYLE_ATTR_RE = re.compile(r"""\sstyle\s*=\s*["']""", re.IGNORECASE)
# Erfasst <script ...>...</script>-Bloecke inkl. ihrer Attribute und ihres
# Inhalts, um zwischen externen (<script src="...">) und inline eingebetteten
# Scripts unterscheiden zu koennen.
_SCRIPT_TAG_RE = re.compile(r"<script\b([^>]*)>(.*?)</script>", re.IGNORECASE | re.DOTALL)
def _assert_no_inline_style(html: str, page: str) -> None:
match = _INLINE_STYLE_ATTR_RE.search(html)
assert match is None, (
f"{page}: gefundenes Inline-style-Attribut verletzt die CSP "
f"(style-src 'self', kein 'unsafe-inline'): {html[match.start():match.start()+60]!r}"
if match else ""
)
def _assert_no_inline_script(html: str, page: str) -> None:
for attrs, body in _SCRIPT_TAG_RE.findall(html):
has_src = re.search(r"\bsrc\s*=", attrs, re.IGNORECASE) is not None
if not has_src and body.strip():
pytest.fail(
f"{page}: <script>-Block ohne src mit Inhalt gefunden -- verletzt "
f"die CSP (script-src 'self', kein 'unsafe-inline'): {body.strip()[:80]!r}"
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path",
["/", "/dashboard", "/terminal/1", "/rdp/1"],
)
async def test_rendered_pages_contain_no_inline_style_or_script(client, path):
resp = await client.get(path)
assert resp.status_code == 200, resp.text
html = resp.text
_assert_no_inline_style(html, path)
_assert_no_inline_script(html, path)
@pytest.mark.asyncio
async def test_login_page_hidden_sections_use_css_class_not_inline_style(client):
"""Die anfangs versteckten Login-Bereiche muessen ueber die `.hidden`
Klasse ausgeblendet werden (per CSS aus app.css, CSP-konform) statt ueber
ein Inline-style-Attribut, das der Browser sonst ignoriert."""
resp = await client.get("/")
assert resp.status_code == 200, resp.text
html = resp.text
for section_id in ("totp-fields", "enroll-box", "recovery-box"):
pattern = re.compile(rf'id="{section_id}"[^>]*class="[^"]*\bhidden\b[^"]*"')
assert pattern.search(html), (
f"#{section_id} sollte die CSS-Klasse 'hidden' tragen (Sichtbarkeits-"
f"Toggle CSP-konform ueber app.css), nicht ein Inline-style-Attribut"
)
@pytest.mark.asyncio
@pytest.mark.parametrize("path,expected_host_id", [("/terminal/42", "42"), ("/rdp/7", "7")])
async def test_session_pages_expose_host_id_via_data_attribute(client, path, expected_host_id):
"""host_id muss CSP-konform (kein Inline-<script>) an das Frontend-JS
uebergeben werden -- ueber data-host-id auf #session-shell."""
resp = await client.get(path)
assert resp.status_code == 200, resp.text
assert f'data-host-id="{expected_host_id}"' in resp.text
assert "JUMPHOST_HOST_ID" not in resp.text
@pytest.mark.asyncio
async def test_security_headers_still_forbid_unsafe_inline(client):
"""Stellt sicher, dass die Loesung NICHT darueber erreicht wurde, die CSP
aufzuweichen (z.B. 'unsafe-inline' hinzuzufuegen) -- die Policy muss so
restriktiv bleiben wie in Konzept 6.6 gefordert."""
resp = await client.get("/")
csp = resp.headers.get("content-security-policy", "")
assert "unsafe-inline" not in csp
assert "script-src 'self'" in csp
assert "style-src 'self'" in csp