hugin/server/tests/test_security.py
2026-10-27 08:33:21 +01:00

143 lines
6.6 KiB
Python

"""Härtungen aus dem Security-Review (→ KONZEPT.md 16)."""
import io
import zipfile
import pytest
from fastapi.testclient import TestClient
from hugin_app.app import create_app
from hugin_app.rate_limit import Window
from hugin_app.settings import Settings
from hugin_app.hugin import Hugin
from hugin_app.migration import migrate
from test_app import ADMIN, tenant_package, env, zip_from_files # noqa: F401
ZIP = {"Content-Type": "application/zip"}
MANIFEST = "id: shop-x\nkind: rules\nversion: 1.0.0\nowner: api\nscope: tenant\ndescription: x\n"
@pytest.fixture
def small(database_url) -> TestClient:
migrate(database_url)
hugin = Hugin(Settings(database_url=database_url, admin_token="admin-geheim", max_upload_mb=1))
return TestClient(create_app(hugin))
def test_large_bodies_rejected_before_login(small):
large = b"\0" * ((1 << 20) + 1)
# mit Content-Length: sofort, auch ohne Key
assert small.post("/api/v1/validate", content=large, headers=ZIP).status_code == 413
# ohne Content-Length (chunked): sobald die Grenze erreicht ist
read = 0
def chunked():
nonlocal read
for _ in range(8):
read += 256 << 10
yield b"\0" * (256 << 10)
assert small.post("/api/v1/validate", content=chunked(), headers=ZIP).status_code == 413
# kleine Bodies gehen wie gewohnt bis zur Anmeldung
assert small.post("/api/v1/validate", content=b"x", headers=ZIP).status_code == 401
def test_no_pages_in_browser_cache(env):
assert env.client.get("/help").headers["cache-control"] == "no-store"
assert env.client.get("/api/v1/packages", headers=env.h("shop-lesen")).headers["cache-control"] == "no-store"
assert "no-store" not in env.client.get("/static/css/hugin.css").headers.get("cache-control", "")
@pytest.mark.parametrize("name", ["a:b.md", "nul.md", "CON", "x/file.", "datei ", "a|b", "frage?.md"])
def test_filenames_for_all_systems(env, name):
raw = zip_from_files({"hugin.yaml": MANIFEST, "RULES.md": "x\n", name: "x"})
response = env.client.post("/api/v1/validate", content=raw, headers=env.h("shop-autor") | ZIP)
assert response.status_code == 422 and "nicht auf allen Systemen" in response.text
def test_broken_zips_without_server_error(env):
raw = bytearray(zip_from_files({"hugin.yaml": MANIFEST, "RULES.md": "x" * 200}))
position = raw.find(b"xxxx")
raw[position:position + 4] = b"yyyy" # Prüfsumme stimmt nicht mehr
not_utf8 = zip_from_files({"hugin.yaml": "x"}).replace(b"x", b"\xff", 1)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as z:
z.writestr("hugin.yaml", b"id: \xff\n")
for body in (bytes(raw), buffer.getvalue(), not_utf8, b"PK\x03\x04kaputt"):
response = env.client.post("/api/v1/validate", content=body, headers=env.h("shop-autor") | ZIP)
assert response.status_code == 422, response.text
def test_too_many_files(env):
files = {"hugin.yaml": MANIFEST, "RULES.md": "x"} | {f"d/{i}.md": "x" for i in range(501)}
response = env.client.post("/api/v1/validate", content=zip_from_files(files),
headers=env.h("shop-autor") | ZIP)
assert response.status_code == 422 and "500 Dateien" in response.text
def test_linter_reveals_no_foreign_versions(env):
tenant_package(env) # shop-intern 1.0.0, nur im Tenant shop sichtbar
manifest = MANIFEST.replace("shop-x", "shop-intern").replace("owner: api", "owner: crm")
raw = zip_from_files({"hugin.yaml": manifest, "RULES.md": "x\n"})
foreign = env.client.post("/api/v1/validate", content=raw, headers=env.h("vertrieb-lesen") | ZIP).json()
assert "1.0.0 muss größer" not in str(foreign["findings"])
own = env.client.post("/api/v1/validate", content=raw, headers=env.h("shop-lesen") | ZIP).json()
assert "muss größer sein als 1.0.0" in str(own["findings"])
def test_rate_limit_memory_stays_limited(monkeypatch):
now = [1000.0]
monkeypatch.setattr("hugin_app.rate_limit.time.monotonic", lambda: now[0])
f = Window(5, 60)
for i in range(12_000):
f.allow(f"zufall-{i}")
now[0] += 61 # alle abgelaufen
f.allow("new")
assert len(f._events) == 1
# innerhalb eines Fensters wird nicht bei jeder Anfrage neu aufgeräumt
for i in range(12_000):
f.allow(f"mehr-{i}")
assert len(f._events) == 12_001
def test_proxy_only_trusted(monkeypatch):
from uvicorn.middleware.proxy_headers import _TrustedHosts
monkeypatch.delenv("HUGIN_PROXY_IPS", raising=False)
default = Settings.from_env().proxy_ips
assert "1.2.3.4" not in _TrustedHosts(default) and "10.0.0.5" not in _TrustedHosts(default)
monkeypatch.setenv("HUGIN_PROXY_IPS", "10.128.0.0/14")
assert "10.128.3.7" in _TrustedHosts(Settings.from_env().proxy_ips)
def test_integrations_key_only_for_delivery(env):
env.example("pub-hausregeln/1.0.0")
env.key("shop", "shop-integration", kind="integration")
h = env.h("shop-integration")
c = env.client
assert c.get("/api/v1/delivery/status", headers=h).status_code == 200
for response in (c.get("/api/v1/packages", headers=h),
c.post("/api/v1/bundle", headers=h, json={"packages": ["pub-hausregeln"], "tools": {"opencode": "1.19.3"}}),
c.get("/api/v1/admin/settings", headers=h)):
assert response.status_code == 403 and "nur für /api/v1/delivery" in response.text
mcp = c.post("/mcp", headers=h | {"Accept": "application/json, text/event-stream"},
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
assert mcp.status_code == 401
web = TestClient(c.app, follow_redirects=False)
response = web.post("/login", data={"key": env.keys["shop-integration"], "next": ""})
assert response.status_code == 403 and "hugin_session" not in response.headers.get("set-cookie", "")
def test_admin_session_ends_with_new_token(database_url):
migrate(database_url)
old = Hugin(Settings(database_url=database_url, admin_token="alt-geheim"))
web = TestClient(create_app(old), follow_redirects=False)
assert web.post("/login", data={"key": "alt-geheim", "next": ""}).status_code == 303
assert web.get("/manage").status_code == 200
# Neustart mit gewechseltem Token, gleiche Datenbank, gleiches Cookie
new = TestClient(create_app(Hugin(Settings(database_url=database_url, admin_token="neu-geheim"))),
follow_redirects=False, cookies=web.cookies)
assert new.get("/manage").status_code == 303 # zurück zur Anmeldung
without = TestClient(create_app(Hugin(Settings(database_url=database_url, admin_token=""))),
follow_redirects=False, cookies=web.cookies)
assert without.get("/manage").status_code == 303