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

@ -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