Files
volumen/tests/test_security.py
T
petrbalvin 3b8ed31f67
Test / test (push) Successful in 1m7s
Release / release (push) Successful in 1m11s
chore: prepare release v0.4.0
2026-07-26 18:15:29 +02:00

423 lines
15 KiB
Python

"""Security-focused tests: signature validation, sanitisation, cookies, headers."""
from __future__ import annotations
import io
from fastapi.testclient import TestClient
from volumen.markdown import render
from volumen.password import (
BLOCK_R,
COST_N,
KEY_LENGTH,
PARALLEL_P,
hash_password,
needs_rehash,
verify_password,
)
from volumen.store import (
ALLOWED_IMAGE_EXTENSIONS,
detect_image_extension,
validate_image_signature,
)
# ---------------------------------------------------------------------------
# Image signature validation
# ---------------------------------------------------------------------------
class TestImageSignature:
def test_webp_magic_accepted(self) -> None:
# Minimal RIFF/WEBP header (12 bytes).
good = b"RIFF" + b"\x10\x00\x00\x00" + b"WEBP" + b"VP8 "
assert validate_image_signature(good, ".webp") is True
def test_webp_wrong_magic_rejected(self) -> None:
# RIFF but not WEBP at offset 8.
bad = b"RIFF" + b"\x10\x00\x00\x00" + b"WAVE" + b"VP8 "
assert validate_image_signature(bad, ".webp") is False
def test_webp_too_short_rejected(self) -> None:
assert validate_image_signature(b"RIFF", ".webp") is False
def test_avif_magic_accepted(self) -> None:
# ftyp box with size=0x18, brand=avif.
avif = b"\x00\x00\x00\x18ftyp" + b"avif" + b"\x00\x00\x00\x00" + b"avif" + b"mif1"
assert validate_image_signature(avif, ".avif") is True
def test_avif_alt_brand_accepted(self) -> None:
avif = b"\x00\x00\x00\x18ftyp" + b"avis" + b"\x00\x00\x00\x00" + b"avis" + b"mif1"
assert validate_image_signature(avif, ".avif") is True
def test_avif_wrong_brand_rejected(self) -> None:
avif = b"\x00\x00\x00\x18ftyp" + b"mp42" + b"\x00\x00\x00\x00" + b"mp42" + b"isom"
assert validate_image_signature(avif, ".avif") is False
def test_avif_too_short_rejected(self) -> None:
assert validate_image_signature(b"\x00\x00\x00\x18ftyp", ".avif") is False
def test_unknown_extension_rejected(self) -> None:
assert validate_image_signature(b"anything", ".png") is False
def test_detect_extension_webp(self) -> None:
data = b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP"
assert detect_image_extension(data) == ".webp"
def test_detect_extension_avif(self) -> None:
data = b"\x00\x00\x00\x18ftypavif" + b"\x00" * 8
assert detect_image_extension(data) == ".avif"
def test_detect_extension_unknown(self) -> None:
assert detect_image_extension(b"random") is None
def test_allowed_extensions_match_policy(self) -> None:
assert ALLOWED_IMAGE_EXTENSIONS == (".webp", ".avif")
# ---------------------------------------------------------------------------
# Markdown XSS sanitisation
# ---------------------------------------------------------------------------
class TestMarkdownSanitisation:
def test_script_tag_stripped(self) -> None:
rendered = render("Hello <script>alert(1)</script>world")
assert "<script" not in rendered.lower()
assert "alert(1)" not in rendered
def test_inline_event_handler_stripped(self) -> None:
rendered = render('<img src="x" onerror="alert(1)">')
assert "onerror" not in rendered.lower()
assert "alert" not in rendered.lower()
def test_javascript_url_sanitised(self) -> None:
rendered = render("[click me](javascript:alert(1))")
assert "javascript:" not in rendered.lower()
def test_data_image_url_kept(self) -> None:
rendered = render("![alt](data:image/webp;base64,UklGRg==)")
assert "<img" in rendered
def test_figure_wrapping_still_works(self) -> None:
rendered = render('![alt](url "caption")')
assert "<figure>" in rendered
assert "caption" in rendered
# ---------------------------------------------------------------------------
# Password hashing policy
# ---------------------------------------------------------------------------
class TestPasswordPolicy:
def test_strong_hash_passes(self) -> None:
encoded = hash_password("a-strong-password")
assert verify_password("a-strong-password", encoded) is True
def test_weak_params_rejected(self) -> None:
encoded = f"scrypt$8$1$1${'A' * 24}${'B' * 32}"
assert verify_password("anything", encoded) is False
def test_needs_rehash_for_strong_hash(self) -> None:
encoded = hash_password("a-strong-password")
assert needs_rehash(encoded) is False
def test_needs_rehash_for_weak_hash(self) -> None:
encoded = f"scrypt$8$1$1${'A' * 24}${'B' * 32}"
assert needs_rehash(encoded) is True
def test_needs_rehash_for_malformed(self) -> None:
assert needs_rehash("not-a-hash") is True
def test_module_constants_match_policy(self) -> None:
# Policy floor equals current implementation.
assert COST_N >= 16384
assert BLOCK_R >= 8
assert PARALLEL_P >= 1
assert KEY_LENGTH >= 32
# ---------------------------------------------------------------------------
# HTTP security headers and CSP
# ---------------------------------------------------------------------------
class TestSecurityHeaders:
def test_global_security_headers_present(self, client: TestClient) -> None:
response = client.get("/api/volumen/site")
assert response.headers.get("x-content-type-options") == "nosniff"
assert response.headers.get("x-frame-options") == "DENY"
assert response.headers.get("referrer-policy") == "strict-origin-when-cross-origin"
assert "permissions-policy" in response.headers
def test_csp_on_public_routes(self, client: TestClient) -> None:
response = client.get("/api/volumen/site")
csp = response.headers.get("content-security-policy", "")
assert "default-src 'self'" in csp
# Public routes have script-src but without nonce (nonce is admin-only)
assert "script-src 'self'" in csp
assert "nonce-" not in csp
def test_admin_route_has_csp_with_nonce(self, admin_client: TestClient) -> None:
response = admin_client.get("/admin/login")
csp = response.headers.get("content-security-policy", "")
assert "default-src 'self'" in csp
assert "'unsafe-inline'" not in csp
assert "nonce-" in csp
def test_https_only_cookie_unset_when_untrusted(self, client: TestClient) -> None:
# Default trust_proxy=false — X-Forwarded-Proto is ignored.
response = client.get("/admin/login")
# No cookie issued, but the middleware must mark session_secure=False.
assert response.status_code == 200
def test_https_only_cookie_with_trusted_proxy(self, admin_config_dir) -> None:
# Build a fresh app with trust_proxy=True and confirm it boots.
from volumen.app import create_app
from volumen.config import Config
from volumen.store import Store
config = Config.load(path=str(admin_config_dir / "config.toml"))
config._data["server"]["trust_proxy"] = True # type: ignore[index]
store = Store(config.content_dir)
app = create_app(config, store)
client = TestClient(app)
resp = client.get("/admin/login")
assert resp.status_code == 200
def test_cookie_secure_false_without_proxy_or_flag(self, admin_client: TestClient) -> None:
# When trust_proxy=false and cookie_secure=false, the SessionMiddleware
# is configured with https_only=False.
resp = admin_client.get("/admin/login")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Config validation
# ---------------------------------------------------------------------------
class TestConfigValidation:
def test_default_config_is_valid(self, config) -> None:
# Should not raise.
config.validate()
def test_invalid_port_rejected(self, tmp_path) -> None:
from volumen.config import Config
(tmp_path / "posts").mkdir()
cfg = Config.load(
path="/nonexistent.toml",
overrides={
"users_file": str(tmp_path / "users.toml"),
"content": str(tmp_path / "posts"),
},
)
cfg._data["server"]["port"] = 999999 # type: ignore[index]
try:
cfg.validate()
except Exception as exc:
assert "port" in str(exc).lower()
else:
raise AssertionError("expected ConfigError")
def test_invalid_env_rejected(self, tmp_path) -> None:
from volumen.config import Config, ConfigError
cfg = Config.load(
path="/nonexistent.toml",
overrides={"users_file": str(tmp_path / "users.toml")},
)
cfg._data["server"]["env"] = "staging" # type: ignore[index]
try:
cfg.validate()
except ConfigError as exc:
assert "env" in str(exc).lower()
else:
raise AssertionError("expected ConfigError")
def test_invalid_base_url_rejected(self, tmp_path) -> None:
from volumen.config import Config, ConfigError
cfg = Config.load(
path="/nonexistent.toml",
overrides={"users_file": str(tmp_path / "users.toml")},
)
cfg._data["site"]["base_url"] = "not-a-url" # type: ignore[index]
try:
cfg.validate()
except ConfigError as exc:
assert "base_url" in str(exc).lower()
else:
raise AssertionError("expected ConfigError")
def test_production_requires_session_key(self, tmp_path) -> None:
from volumen.config import Config
(tmp_path / "posts").mkdir()
cfg = Config.load(
path="/nonexistent.toml",
overrides={
"users_file": str(tmp_path / "users.toml"),
"content": str(tmp_path / "posts"),
},
)
cfg._data["server"]["env"] = "production" # type: ignore[index]
cfg._data["admin"]["session_key"] = "" # type: ignore[index]
# Validation succeeds (file is allowed) but create_app will refuse.
cfg.validate()
# ---------------------------------------------------------------------------
# Session secret validation
# ---------------------------------------------------------------------------
class TestSessionSecret:
def test_production_without_session_key_fails(self, config) -> None:
from volumen.app import create_app
from volumen.store import Store
config._data["server"]["env"] = "production" # type: ignore[index]
config._data["admin"]["session_key"] = "" # type: ignore[index]
store = Store(config.content_dir)
try:
create_app(config, store)
except RuntimeError as exc:
assert "session_key" in str(exc)
else:
raise AssertionError("expected RuntimeError")
def test_production_with_short_key_fails(self, config) -> None:
from volumen.app import create_app
from volumen.store import Store
config._data["server"]["env"] = "production" # type: ignore[index]
config._data["admin"]["session_key"] = "tooshort" # type: ignore[index]
store = Store(config.content_dir)
try:
create_app(config, store)
except RuntimeError as exc:
assert "64 bytes" in str(exc)
else:
raise AssertionError("expected RuntimeError")
# ---------------------------------------------------------------------------
# Password policy helpers
# ---------------------------------------------------------------------------
class TestPasswordPolicyHelpers:
def test_password_error_min_length(self) -> None:
from volumen.auth import password_error
assert password_error("short", min_length=10) is not None
def test_password_error_max_length(self) -> None:
from volumen.auth import password_error
assert password_error("x" * 2000, min_length=10, max_length=1024) is not None
def test_password_error_empty(self) -> None:
from volumen.auth import password_error
assert password_error("", min_length=10) is not None
def test_password_error_common_rejected(self) -> None:
from volumen.auth import password_error
assert password_error("password", min_length=10) is not None
def test_password_error_accepts_strong(self) -> None:
from volumen.auth import password_error
assert password_error("volumen-strong-pw", min_length=10) is None
# ---------------------------------------------------------------------------
# Store hardening
# ---------------------------------------------------------------------------
class _Upload:
"""Fake UploadFile for testing validate_upload."""
content_type: str
filename: str
file: io.BytesIO
def __init__(
self, data: bytes, content_type: str = "image/webp", filename: str = "x.webp"
) -> None:
self._data = data
self.content_type = content_type
self.filename = filename
self.file = io.BytesIO(data)
self._pos = 0
async def read(self, size: int = -1) -> bytes:
if size == -1:
result = self._data[self._pos :]
self._pos = len(self._data)
return result
result = self._data[self._pos : self._pos + size]
self._pos += size
return result
class TestStoreHardening:
def test_validate_upload_accepts_webp(self, tmp_content_dir) -> None:
# Build a fake UploadFile-shaped object.
good_webp = b"RIFF" + b"\x10\x00\x00\x00" + b"WEBP" + b"\x00" * 100
import asyncio
from volumen.upload_validation import validate_upload
async def go() -> tuple[bytes, str]:
return await validate_upload(_Upload(good_webp), max_bytes=10_000)
data, ext = asyncio.run(go())
assert ext == ".webp"
assert data == good_webp
def test_validate_upload_rejects_mime_mismatch(self, tmp_content_dir) -> None:
import asyncio
from volumen.upload_validation import validate_upload
async def go() -> None:
try:
await validate_upload(
_Upload(b"nope", content_type="image/png", filename="x.png"),
max_bytes=10_000,
)
except Exception as exc:
# The detail is JSON-encoded text.
msg = str(exc)
assert "unsupported_format" in msg or "signature" in msg.lower()
else:
raise AssertionError("expected HTTPException")
asyncio.run(go())
def test_store_upload_uses_signature_extension(self, tmp_content_dir) -> None:
from volumen.store import Store
store = Store(str(tmp_content_dir))
avif_bytes = b"\x00\x00\x00\x18ftyp" + b"avif" + b"\x00" * 100
url = store.store_upload("evil.exe", None, bytes_data=avif_bytes)
assert url.endswith(".avif")
assert "evil" not in url
def test_store_upload_resolves_whitelisted_extension(self, tmp_content_dir) -> None:
from volumen.store import Store
store = Store(str(tmp_content_dir))
webp_bytes = b"RIFF" + b"\x10\x00\x00\x00" + b"WEBP" + b"\x00" * 100
# File name has an unrelated extension; signature wins.
url = store.store_upload("cover.png", None, bytes_data=webp_bytes)
assert url.endswith(".webp")