Spaces:
Sleeping
Sleeping
| """Sprint S8.7 — couverture des helpers env-var fallback et | |
| défense Pillow de ``picarones/interfaces/web/security.py``. | |
| Cible (avant) : 92.18% patch coverage avec 15 lignes manquantes | |
| sur des chemins testables sans mock lourd : | |
| - ``compute_workspace_roots`` avec ``PICARONES_WORKSPACE_ROOTS`` set ; | |
| - ``get_max_upload_mb`` / ``get_max_concurrent_jobs`` / | |
| ``get_rate_limit_per_hour`` sur valeur invalide → fallback log ; | |
| - ``validate_image_safe`` sur ``DecompressionBombError`` (vraie | |
| image bomb simulée via abaissement temporaire de | |
| ``MAX_IMAGE_PIXELS``) ; | |
| - ``_get_csrf_secret`` génère un secret runtime quand | |
| ``PICARONES_CSRF_SECRET`` absent ; | |
| - ``RateLimiter.check`` purge les hits hors fenêtre. | |
| Tous les tests sont des assertions de comportement réel — pas | |
| de simple « ça ne plante pas ». | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import os | |
| import time | |
| import pytest | |
| # ────────────────────────────────────────────────────────────────────── | |
| # Env var fallbacks — doivent retourner le default sur valeur invalide | |
| # ────────────────────────────────────────────────────────────────────── | |
| class TestEnvVarFallbacks: | |
| def test_max_upload_mb_invalid_returns_default( | |
| self, monkeypatch, caplog, | |
| ) -> None: | |
| from picarones.interfaces.web.security import get_max_upload_mb | |
| monkeypatch.setenv("PICARONES_MAX_UPLOAD_MB", "not-a-number") | |
| with caplog.at_level("WARNING"): | |
| value = get_max_upload_mb() | |
| assert value == 100, "default value not returned on invalid env" | |
| assert any( | |
| "PICARONES_MAX_UPLOAD_MB" in rec.message for rec in caplog.records | |
| ), "warning log not emitted on invalid env" | |
| def test_max_upload_mb_valid_overrides_default( | |
| self, monkeypatch, | |
| ) -> None: | |
| from picarones.interfaces.web.security import get_max_upload_mb | |
| monkeypatch.setenv("PICARONES_MAX_UPLOAD_MB", "250") | |
| assert get_max_upload_mb() == 250 | |
| def test_max_upload_mb_clamped_to_one(self, monkeypatch) -> None: | |
| """Valeur ≤ 0 → clampée à 1 (pas un upload de 0 Mo accepté).""" | |
| from picarones.interfaces.web.security import get_max_upload_mb | |
| monkeypatch.setenv("PICARONES_MAX_UPLOAD_MB", "0") | |
| assert get_max_upload_mb() == 1 | |
| def test_max_concurrent_jobs_invalid_returns_default( | |
| self, monkeypatch, caplog, | |
| ) -> None: | |
| from picarones.interfaces.web.security import get_max_concurrent_jobs | |
| monkeypatch.setenv("PICARONES_MAX_CONCURRENT_JOBS", "abc") | |
| with caplog.at_level("WARNING"): | |
| value = get_max_concurrent_jobs() | |
| assert value == 2 | |
| assert any( | |
| "PICARONES_MAX_CONCURRENT_JOBS" in rec.message | |
| for rec in caplog.records | |
| ) | |
| def test_rate_limit_invalid_in_public_mode_returns_default( | |
| self, monkeypatch, | |
| ) -> None: | |
| from picarones.interfaces.web.security import get_rate_limit_per_hour | |
| monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1") | |
| monkeypatch.setenv("PICARONES_RATE_LIMIT_PER_HOUR", "not-int") | |
| assert get_rate_limit_per_hour() == 5 | |
| def test_rate_limit_dev_mode_returns_zero(self, monkeypatch) -> None: | |
| """Hors mode public, pas de rate limit (0 = illimité).""" | |
| from picarones.interfaces.web.security import get_rate_limit_per_hour | |
| monkeypatch.delenv("PICARONES_PUBLIC_MODE", raising=False) | |
| assert get_rate_limit_per_hour() == 0 | |
| # ────────────────────────────────────────────────────────────────────── | |
| # compute_workspace_roots avec env var explicite | |
| # ────────────────────────────────────────────────────────────────────── | |
| class TestComputeWorkspaceRoots: | |
| def test_env_var_overrides_defaults(self, monkeypatch, tmp_path) -> None: | |
| from picarones.interfaces.web.security import compute_workspace_roots | |
| d1 = tmp_path / "ws1" | |
| d2 = tmp_path / "ws2" | |
| d1.mkdir() | |
| d2.mkdir() | |
| monkeypatch.setenv( | |
| "PICARONES_WORKSPACE_ROOTS", f"{d1}{os.pathsep}{d2}", | |
| ) | |
| roots = compute_workspace_roots(tmp_path / "uploads") | |
| # Les deux paths explicites doivent être présents et résolus. | |
| resolved = [r.resolve() for r in roots] | |
| assert d1.resolve() in resolved | |
| assert d2.resolve() in resolved | |
| def test_no_env_var_uses_defaults(self, monkeypatch, tmp_path) -> None: | |
| from picarones.interfaces.web.security import compute_workspace_roots | |
| monkeypatch.delenv("PICARONES_WORKSPACE_ROOTS", raising=False) | |
| uploads = tmp_path / "uploads" | |
| uploads.mkdir() | |
| roots = compute_workspace_roots(uploads) | |
| # Au moins ``uploads`` ou un parent doit être inclus. | |
| resolved = [r.resolve() for r in roots] | |
| assert any( | |
| uploads.resolve() == r or uploads.resolve().is_relative_to(r) | |
| for r in resolved | |
| ) | |
| # ────────────────────────────────────────────────────────────────────── | |
| # validate_image_safe — branche DecompressionBombError | |
| # ────────────────────────────────────────────────────────────────────── | |
| def _tiny_png_bytes() -> bytes: | |
| """Produit un PNG 4×4 minimal (assez pour déclencher la bomb | |
| si ``MAX_IMAGE_PIXELS`` est abaissé à 1).""" | |
| from PIL import Image | |
| img = Image.new("RGB", (4, 4), color=(255, 255, 255)) | |
| buf = io.BytesIO() | |
| img.save(buf, format="PNG") | |
| return buf.getvalue() | |
| class TestValidateImageSafe: | |
| def test_decompression_bomb_rejected(self, monkeypatch) -> None: | |
| """Simule une bomb en abaissant ``MAX_IMAGE_PIXELS`` sous la | |
| taille de l'image — Pillow lève alors | |
| ``DecompressionBombError`` que le helper doit transformer | |
| en ``ValueError`` propre.""" | |
| from PIL import Image | |
| from picarones.interfaces.web.security import validate_image_safe | |
| data = _tiny_png_bytes() | |
| monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 2) | |
| with pytest.raises(ValueError, match="bombe|décompression"): | |
| validate_image_safe(data, filename="bomb.png") | |
| def test_size_limit_enforced(self, monkeypatch) -> None: | |
| """Buffer trop gros → rejet sans tenter Pillow.""" | |
| from picarones.interfaces.web.security import validate_image_safe | |
| monkeypatch.setenv("PICARONES_MAX_UPLOAD_MB", "1") | |
| data = b"\x00" * (2 * 1024 * 1024) # 2 MB > 1 MB limit | |
| with pytest.raises(ValueError, match="taille"): | |
| validate_image_safe(data, filename="big.bin") | |
| def test_valid_image_passes(self) -> None: | |
| """Contrôle positif : image valide → aucune exception.""" | |
| from picarones.interfaces.web.security import validate_image_safe | |
| validate_image_safe(_tiny_png_bytes(), filename="ok.png") # no raise | |
| def test_corrupt_bytes_rejected(self) -> None: | |
| """Données non-image → ``ValueError`` (UnidentifiedImage ou | |
| autre).""" | |
| from picarones.interfaces.web.security import validate_image_safe | |
| with pytest.raises(ValueError): | |
| validate_image_safe(b"not-an-image-at-all", filename="nope.png") | |
| # ────────────────────────────────────────────────────────────────────── | |
| # _get_csrf_secret — fallback runtime | |
| # ────────────────────────────────────────────────────────────────────── | |
| class TestCSRFSecretRuntime: | |
| def test_env_var_used_when_set(self, monkeypatch) -> None: | |
| import picarones.interfaces.web.security as sec | |
| monkeypatch.setenv("PICARONES_CSRF_SECRET", "fixed-secret") | |
| # Reset le runtime secret pour s'assurer qu'on prend bien l'env. | |
| monkeypatch.setattr(sec, "_csrf_secret_runtime", None) | |
| secret = sec._get_csrf_secret() | |
| assert secret == b"fixed-secret" | |
| def test_runtime_generated_when_env_absent( | |
| self, monkeypatch, caplog, | |
| ) -> None: | |
| import picarones.interfaces.web.security as sec | |
| monkeypatch.delenv("PICARONES_CSRF_SECRET", raising=False) | |
| monkeypatch.setattr(sec, "_csrf_secret_runtime", None) | |
| with caplog.at_level("WARNING"): | |
| secret1 = sec._get_csrf_secret() | |
| assert isinstance(secret1, bytes) | |
| assert len(secret1) == 32, "secrets.token_bytes(32) attendu" | |
| # Warning émis pour signaler la config manquante. | |
| assert any( | |
| "PICARONES_CSRF_SECRET" in rec.message for rec in caplog.records | |
| ) | |
| # Appel suivant → même secret (persistant durant la vie du process). | |
| secret2 = sec._get_csrf_secret() | |
| assert secret1 == secret2 | |
| # ────────────────────────────────────────────────────────────────────── | |
| # RateLimiter.check — pruning de la fenêtre | |
| # ────────────────────────────────────────────────────────────────────── | |
| class TestRateLimiterPruning: | |
| def test_prunes_expired_hits(self) -> None: | |
| """Un hit > 1h → purgé du bucket à l'appel suivant. Couvre | |
| la branche ``while bucket and bucket[0] < cutoff: popleft()``.""" | |
| from collections import deque | |
| from picarones.interfaces.web.security import RateLimiter | |
| rl = RateLimiter(max_per_hour=2) | |
| # Pose un hit ancien (> 3600s) directement dans le bucket | |
| # interne pour simuler le passage du temps sans sleep. | |
| rl._buckets["1.2.3.4"] = deque([time.monotonic() - 7200.0]) | |
| rl.check("1.2.3.4") # ne doit pas lever | |
| # Le hit ancien est purgé, seul le nouveau reste. | |
| assert len(rl._buckets["1.2.3.4"]) == 1, ( | |
| "le hit ancien aurait dû être purgé" | |
| ) | |
| def test_quota_exceeded_raises(self) -> None: | |
| from picarones.interfaces.web.security import RateLimiter | |
| rl = RateLimiter(max_per_hour=2) | |
| rl.check("5.6.7.8") | |
| rl.check("5.6.7.8") | |
| with pytest.raises(PermissionError, match="Quota"): | |
| rl.check("5.6.7.8") | |
| def test_disabled_when_max_zero(self) -> None: | |
| """``max_per_hour=0`` → désactivé, jamais de PermissionError.""" | |
| from picarones.interfaces.web.security import RateLimiter | |
| rl = RateLimiter(max_per_hour=0) | |
| for _ in range(100): | |
| rl.check("9.9.9.9") # no raise | |
| class TestEntityExtractorAllowlist: | |
| """Garde-fou P0 — le champ ``entity_extractor`` du payload web | |
| déclenche un ``importlib.import_module`` + appel du symbole résolu. | |
| C'est un gadget d'exécution : il doit être fail-closed sur instance | |
| partagée et explicitement allowlisté en institutionnel.""" | |
| def test_empty_is_noop(self, monkeypatch) -> None: | |
| from picarones.interfaces.web.security import ( | |
| assert_entity_extractor_allowed, | |
| ) | |
| monkeypatch.delenv("PICARONES_PUBLIC_MODE", raising=False) | |
| monkeypatch.delenv( | |
| "PICARONES_ENTITY_EXTRACTOR_ALLOWLIST", raising=False, | |
| ) | |
| assert_entity_extractor_allowed("") # no raise | |
| assert_entity_extractor_allowed(" ") # no raise | |
| def test_public_mode_without_allowlist_rejects( | |
| self, monkeypatch, | |
| ) -> None: | |
| from picarones.interfaces.web.security import ( | |
| assert_entity_extractor_allowed, | |
| ) | |
| monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1") | |
| monkeypatch.delenv( | |
| "PICARONES_ENTITY_EXTRACTOR_ALLOWLIST", raising=False, | |
| ) | |
| with pytest.raises(PermissionError, match="entity_extractor"): | |
| assert_entity_extractor_allowed("os:getcwd") | |
| def test_non_public_without_allowlist_also_rejects( | |
| self, monkeypatch, | |
| ) -> None: | |
| """P0.2 — fail-closed strict : même HORS mode public, sans | |
| allowlist, le web refuse (la surface réseau est dangereuse | |
| quel que soit le mode ; l'ancienne tolérance était un trou).""" | |
| from picarones.interfaces.web.security import ( | |
| assert_entity_extractor_allowed, | |
| ) | |
| monkeypatch.delenv("PICARONES_PUBLIC_MODE", raising=False) | |
| monkeypatch.delenv( | |
| "PICARONES_ENTITY_EXTRACTOR_ALLOWLIST", raising=False, | |
| ) | |
| with pytest.raises(PermissionError, match="désactivé côté web"): | |
| assert_entity_extractor_allowed("mypkg.ner:Extractor") | |
| def test_allowlist_match_allowed_all_modes( | |
| self, monkeypatch, | |
| ) -> None: | |
| from picarones.interfaces.web.security import ( | |
| assert_entity_extractor_allowed, | |
| ) | |
| monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1") | |
| monkeypatch.setenv( | |
| "PICARONES_ENTITY_EXTRACTOR_ALLOWLIST", | |
| "mypkg.ner:Extractor, other.mod:Fn", | |
| ) | |
| assert_entity_extractor_allowed("mypkg.ner:Extractor") # no raise | |
| assert_entity_extractor_allowed("other.mod:Fn") # no raise | |
| def test_allowlist_set_rejects_unlisted_even_non_public( | |
| self, monkeypatch, | |
| ) -> None: | |
| """Allowlist définie ⇒ s'applique dans tous les modes : un | |
| dotted path hors liste est refusé même hors mode public.""" | |
| from picarones.interfaces.web.security import ( | |
| assert_entity_extractor_allowed, | |
| ) | |
| monkeypatch.delenv("PICARONES_PUBLIC_MODE", raising=False) | |
| monkeypatch.setenv( | |
| "PICARONES_ENTITY_EXTRACTOR_ALLOWLIST", "mypkg.ner:Extractor", | |
| ) | |
| with pytest.raises(PermissionError, match="hors allowlist"): | |
| assert_entity_extractor_allowed("os:system") | |
| class TestSecureCookies: | |
| """``secure_cookies`` — fin du ``secure=False`` codé en dur.""" | |
| def _clear(self, mp): | |
| for v in ( | |
| "PICARONES_SECURE_COOKIES", "PICARONES_PUBLIC_MODE", "SPACE_ID", | |
| ): | |
| mp.delenv(v, raising=False) | |
| def test_explicit_env_wins(self, monkeypatch) -> None: | |
| from picarones.interfaces.web.security import secure_cookies | |
| self._clear(monkeypatch) | |
| monkeypatch.setenv("PICARONES_SECURE_COOKIES", "1") | |
| assert secure_cookies() is True | |
| monkeypatch.setenv("PICARONES_SECURE_COOKIES", "0") | |
| assert secure_cookies() is False | |
| def test_local_dev_default_false(self, monkeypatch) -> None: | |
| from picarones.interfaces.web.security import secure_cookies | |
| self._clear(monkeypatch) | |
| assert secure_cookies() is False | |
| def test_hf_space_default_true(self, monkeypatch) -> None: | |
| """HF Space = HTTPS certain ⇒ Secure par défaut.""" | |
| from picarones.interfaces.web.security import secure_cookies | |
| self._clear(monkeypatch) | |
| monkeypatch.setenv("SPACE_ID", "user/space") | |
| assert secure_cookies() is True | |
| def test_public_mode_alone_does_not_imply_secure( | |
| self, monkeypatch, | |
| ) -> None: | |
| """Axe découplé : « mutualisé » n'implique pas « HTTPS ». | |
| Le compose local est public_mode=1 mais http://127.0.0.1 — | |
| un cookie Secure y serait silencieusement ignoré.""" | |
| from picarones.interfaces.web.security import secure_cookies | |
| self._clear(monkeypatch) | |
| monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1") | |
| assert secure_cookies() is False | |
| class TestDeploymentCoherence: | |
| """``check_deployment_coherence`` — refuse une combinaison | |
| exposée + CSRF requis + cookies en clair (fail-fast démarrage).""" | |
| def _clear(self, mp): | |
| for v in ( | |
| "PICARONES_SECURE_COOKIES", "PICARONES_PUBLIC_MODE", | |
| "PICARONES_CSRF_REQUIRED", "SPACE_ID", | |
| ): | |
| mp.delenv(v, raising=False) | |
| def test_local_csrf_without_secure_is_tolerated( | |
| self, monkeypatch, | |
| ) -> None: | |
| from picarones.interfaces.web.security import ( | |
| check_deployment_coherence, | |
| ) | |
| self._clear(monkeypatch) | |
| monkeypatch.setenv("PICARONES_CSRF_REQUIRED", "1") | |
| check_deployment_coherence() # non exposé → pas de raise | |
| def test_exposed_csrf_without_secure_cookies_blocks_startup( | |
| self, monkeypatch, | |
| ) -> None: | |
| from picarones.interfaces.web.security import ( | |
| check_deployment_coherence, | |
| ) | |
| self._clear(monkeypatch) | |
| monkeypatch.setenv("PICARONES_CSRF_REQUIRED", "1") | |
| monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1") | |
| monkeypatch.setenv("PICARONES_SECURE_COOKIES", "0") | |
| with pytest.raises(RuntimeError, match="incohérente"): | |
| check_deployment_coherence() | |
| def test_exposed_csrf_with_secure_cookies_ok( | |
| self, monkeypatch, | |
| ) -> None: | |
| from picarones.interfaces.web.security import ( | |
| check_deployment_coherence, | |
| ) | |
| self._clear(monkeypatch) | |
| monkeypatch.setenv("PICARONES_CSRF_REQUIRED", "1") | |
| monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1") | |
| monkeypatch.setenv("PICARONES_SECURE_COOKIES", "1") | |
| check_deployment_coherence() # cohérent → pas de raise | |
| class TestUploadHelpersEdgeBranches: | |
| """Couvre les branches défensives P0.5 de ``security_uploads``.""" | |
| def test_max_total_upload_invalid_env_returns_default( | |
| self, monkeypatch, | |
| ) -> None: | |
| from picarones.interfaces.web.security import get_max_total_upload_mb | |
| monkeypatch.setenv("PICARONES_MAX_TOTAL_UPLOAD_MB", "pas-un-int") | |
| assert get_max_total_upload_mb() == 500 | |
| def test_validate_image_file_safe_rejects_oversized( | |
| self, monkeypatch, tmp_path, | |
| ) -> None: | |
| from picarones.interfaces.web.security import validate_image_file_safe | |
| monkeypatch.setenv("PICARONES_MAX_UPLOAD_MB", "1") | |
| big = tmp_path / "big.png" | |
| big.write_bytes(b"\x00" * (2 * 1024 * 1024)) # 2 Mo > 1 Mo | |
| with pytest.raises(ValueError, match="PICARONES_MAX_UPLOAD_MB"): | |
| validate_image_file_safe(big) | |