152 lines
6.5 KiB
Python
152 lines
6.5 KiB
Python
"""
|
|
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", "/admin"],
|
|
)
|
|
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_api_docs_page_contains_no_inline_style_or_script(client):
|
|
"""/docs (siehe test_admin_groups_tokens.py fuer den Admin-Zugriffsschutz
|
|
selbst) ist bewusst KEIN vendored/CDN-bezogenes Swagger-UI-Bundle, sondern
|
|
eine selbstgebaute, CSP-konforme Ansicht -- muss also denselben
|
|
Inline-Regeln genuegen wie alle anderen Seiten."""
|
|
from app.db import get_db
|
|
from app.security.passwords import hash_password
|
|
|
|
conn = get_db()
|
|
await conn.execute(
|
|
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
|
|
"VALUES ('docsadmin', ?, 1, 0)",
|
|
(hash_password("Correct-Horse-Battery-Staple-Docs"),),
|
|
)
|
|
await conn.commit()
|
|
resp = await client.post(
|
|
"/auth/login", json={"username": "docsadmin", "password": "Correct-Horse-Battery-Staple-Docs"}
|
|
)
|
|
pending = resp.json()["pending_token"]
|
|
import pyotp
|
|
resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending})
|
|
provisioning_uri = resp.json()["provisioning_uri"]
|
|
secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"]
|
|
code = pyotp.TOTP(secret).now()
|
|
await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code})
|
|
|
|
resp = await client.get("/docs")
|
|
assert resp.status_code == 200, resp.text
|
|
html = resp.text
|
|
_assert_no_inline_style(html, "/docs")
|
|
_assert_no_inline_script(html, "/docs")
|
|
|
|
|
|
@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
|