fix 13
This commit is contained in:
@ -57,6 +57,12 @@ button:hover { filter: brightness(1.1); }
|
||||
.qr { display: block; margin: 1rem auto; 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 {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border); background: var(--panel);
|
||||
|
||||
@ -41,14 +41,14 @@
|
||||
const result = await postJson("/auth/login", { username, password });
|
||||
pendingToken = result.pending_token;
|
||||
|
||||
passwordFields.style.display = "none";
|
||||
totpFields.style.display = "block";
|
||||
passwordFields.classList.add("hidden");
|
||||
totpFields.classList.remove("hidden");
|
||||
|
||||
if (!result.totp_enrolled) {
|
||||
mode = "enroll_start";
|
||||
const enroll = await postJson("/auth/totp/enroll/start", { pending_token: pendingToken });
|
||||
document.getElementById("qr-img").src = "data:image/png;base64," + enroll.qr_png_base64;
|
||||
enrollBox.style.display = "block";
|
||||
enrollBox.classList.remove("hidden");
|
||||
mode = "enroll_confirm";
|
||||
submitBtn.textContent = "TOTP bestaetigen & einrichten";
|
||||
} else {
|
||||
@ -68,7 +68,7 @@
|
||||
if (mode === "enroll_confirm") {
|
||||
const code = document.getElementById("totp-code").value.trim();
|
||||
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");
|
||||
submitBtn.textContent = "Weiter zum Dashboard";
|
||||
mode = "done";
|
||||
|
||||
@ -11,9 +11,9 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const hostId = window.JUMPHOST_HOST_ID;
|
||||
const statusEl = document.getElementById("status");
|
||||
const shell = document.getElementById("session-shell");
|
||||
const hostId = shell.dataset.hostId;
|
||||
const statusEl = document.getElementById("status");
|
||||
const displayDiv = document.getElementById("rdp-display");
|
||||
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
@ -9,9 +9,9 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const hostId = window.JUMPHOST_HOST_ID;
|
||||
const statusEl = document.getElementById("status");
|
||||
const shell = document.getElementById("session-shell");
|
||||
const hostId = shell.dataset.hostId;
|
||||
const statusEl = document.getElementById("status");
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
|
||||
@ -19,18 +19,18 @@
|
||||
<input type="password" id="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<div id="totp-fields" style="display:none">
|
||||
<div id="totp-fields" class="hidden">
|
||||
<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">
|
||||
<p class="hint">Bei erstmaliger Anmeldung: Secret unten scannen, danach den 6-stelligen Code eingeben.</p>
|
||||
</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">
|
||||
<p class="hint">Mit einer Authenticator-App (z.B. Aegis, FreeOTP) scannen.</p>
|
||||
</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>
|
||||
<div id="recovery-codes" class="recovery-codes"></div>
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="session-shell" id="session-shell">
|
||||
<div class="session-shell" id="session-shell" data-host-id="{{ host_id }}">
|
||||
<div class="session-toolbar">
|
||||
<button id="fullscreen-btn">Vollbild</button>
|
||||
<span class="spacer"></span>
|
||||
@ -17,7 +17,6 @@
|
||||
</div>
|
||||
|
||||
<script src="/static/js/vendor/guacamole-common.js"></script>
|
||||
<script>window.JUMPHOST_HOST_ID = {{ host_id }};</script>
|
||||
<script src="/static/js/rdp.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -8,11 +8,11 @@
|
||||
<link rel="stylesheet" href="/static/js/vendor/xterm.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="session-shell" id="session-shell">
|
||||
<div class="session-shell" id="session-shell" data-host-id="{{ host_id }}">
|
||||
<div class="session-toolbar">
|
||||
<button id="fullscreen-btn">Vollbild</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="status" id="status">Verbinde ...</span>
|
||||
</div>
|
||||
@ -21,7 +21,6 @@
|
||||
|
||||
<script src="/static/js/vendor/xterm.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
117
tests/test_csp_compliance.py
Normal file
117
tests/test_csp_compliance.py
Normal 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
|
||||
Reference in New Issue
Block a user