"""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 world") assert " None: rendered = render('') 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 " None: rendered = render('![alt](url "caption")') assert "
" 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")