fix 13
This commit is contained in:
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