Spaces:
Sleeping
Sleeping
File size: 8,118 Bytes
a2bea75 6b429be a2bea75 de9192c a2bea75 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | """Sprint A14-S1 β A.I.0 P0 : validation des chemins utilisateur.
Tests sur ``picarones.interfaces.web.security.validated_path``,
``validated_prompt_filename`` et ``safe_report_name`` : les helpers
introduits pour bloquer les chemins arbitraires reΓ§us des endpoints
benchmark/run et benchmark/start.
Avant le sprint S1 du rewrite ciblΓ©, l'API web acceptait :
- n'importe quel ``corpus_path`` validΓ© uniquement par ``Path.exists()`` ;
- n'importe quel ``output_dir`` créé par ``Path(req.output_dir).mkdir()`` ;
- n'importe quel ``report_name`` concatΓ©nΓ© directement (escape via ``../``) ;
- n'importe quel ``prompt_file`` absolu (vecteur d'exfiltration via LLM).
Les tests ci-dessous font office de filet de sΓ©curitΓ©. Toute Γ©volution
ultΓ©rieure de la couche security.py qui ferait rΓ©gresser ces invariants
est bloquΓ©e par cette suite.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
import pytest
from picarones.interfaces.web.security import (
PathValidationError,
safe_report_name,
validated_path,
validated_prompt_filename,
)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# validated_path
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class TestValidatedPath:
def test_accepts_path_within_allowed_root(self, tmp_path: Path) -> None:
sub = tmp_path / "corpus_a"
sub.mkdir()
result = validated_path(str(sub), allowed_roots=[tmp_path], must_be_dir=True)
assert result == sub.resolve()
def test_rejects_path_outside_allowed_roots(self, tmp_path: Path) -> None:
# /etc/passwd existe sur tout Linux et est clairement hors workspace.
with pytest.raises(PathValidationError, match="hors zone autorisΓ©e"):
validated_path("/etc/passwd", allowed_roots=[tmp_path])
def test_rejects_traversal_via_dot_dot(self, tmp_path: Path) -> None:
sub = tmp_path / "inside"
sub.mkdir()
# tmp_path/inside/../../../etc β rΓ©solu = /etc β hors zone
evasion = str(sub / ".." / ".." / ".." / "etc")
with pytest.raises(PathValidationError, match="hors zone autorisΓ©e"):
validated_path(evasion, allowed_roots=[tmp_path])
def test_rejects_empty_path(self, tmp_path: Path) -> None:
with pytest.raises(PathValidationError, match="vide"):
validated_path("", allowed_roots=[tmp_path])
def test_rejects_null_byte(self, tmp_path: Path) -> None:
with pytest.raises(PathValidationError, match="octet nul"):
validated_path("foo\x00bar", allowed_roots=[tmp_path])
def test_rejects_when_no_allowed_roots(self, tmp_path: Path) -> None:
with pytest.raises(PathValidationError, match="Aucune racine autorisΓ©e"):
validated_path(str(tmp_path), allowed_roots=[])
def test_must_exist_raises_on_missing(self, tmp_path: Path) -> None:
missing = tmp_path / "does_not_exist"
with pytest.raises(PathValidationError, match="inexistant"):
validated_path(str(missing), allowed_roots=[tmp_path], must_exist=True)
def test_must_be_dir_raises_on_file(self, tmp_path: Path) -> None:
f = tmp_path / "a_file.txt"
f.write_text("hello")
with pytest.raises(PathValidationError, match="n'est pas un rΓ©pertoire"):
validated_path(str(f), allowed_roots=[tmp_path], must_be_dir=True)
def test_resolves_symlinks(self, tmp_path: Path) -> None:
# Si on crΓ©e un symlink dans tmp_path qui pointe vers /tmp/ailleurs,
# ``resolve()`` doit suivre le symlink. Si la cible est hors zone,
# on rejette.
outside = Path(tempfile.mkdtemp(prefix="picarones_outside_"))
try:
link = tmp_path / "tricky_link"
link.symlink_to(outside)
with pytest.raises(PathValidationError, match="hors zone autorisΓ©e"):
validated_path(str(link), allowed_roots=[tmp_path])
finally:
# cleanup
outside.rmdir()
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# safe_report_name
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class TestSafeReportName:
def test_accepts_simple_name(self) -> None:
assert safe_report_name("rapport_2026") == "rapport_2026"
def test_strips_path_separators(self) -> None:
# Les sΓ©parateurs sont supprimΓ©s silencieusement.
# ``../etc/passwd`` β ``..etcpasswd``, et ``..`` initial est strippΓ© β
# ``etcpasswd`` (caractères neutres, pas de chemin).
result = safe_report_name("../etc/passwd")
assert "/" not in result
assert "\\" not in result
def test_rejects_empty(self) -> None:
with pytest.raises(PathValidationError, match="vide"):
safe_report_name("")
def test_rejects_null_byte(self) -> None:
with pytest.raises(PathValidationError, match="octet nul"):
safe_report_name("rapport\x00.html")
def test_rejects_pure_separators(self) -> None:
with pytest.raises(PathValidationError, match="invalide"):
safe_report_name("///")
def test_rejects_dot_only(self) -> None:
with pytest.raises(PathValidationError):
safe_report_name(".")
def test_truncates_to_max_length(self) -> None:
long_name = "a" * 500
assert len(safe_report_name(long_name, max_length=128)) == 128
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# validated_prompt_filename
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class TestValidatedPromptFilename:
def test_accepts_builtin_name(self) -> None:
assert (
validated_prompt_filename("correction_medieval_french.txt")
== "correction_medieval_french.txt"
)
def test_rejects_absolute_path(self) -> None:
with pytest.raises(PathValidationError, match="sΓ©parateur de chemin"):
validated_prompt_filename("/etc/passwd")
def test_rejects_relative_traversal(self) -> None:
with pytest.raises(PathValidationError):
validated_prompt_filename("../prompts/secret.txt")
def test_rejects_dot_dot_inline(self) -> None:
with pytest.raises(PathValidationError, match="suspect"):
validated_prompt_filename("foo..bar.txt")
def test_rejects_windows_separator(self) -> None:
with pytest.raises(PathValidationError, match="sΓ©parateur de chemin"):
validated_prompt_filename(r"C:\Users\victim\file.txt")
def test_rejects_dot_prefix(self) -> None:
with pytest.raises(PathValidationError, match="suspect"):
validated_prompt_filename(".env")
def test_rejects_null_byte(self) -> None:
with pytest.raises(PathValidationError, match="octet nul"):
validated_prompt_filename("file\x00.txt")
def test_rejects_control_characters(self) -> None:
with pytest.raises(PathValidationError, match="caractère de contrôle"):
validated_prompt_filename("file\x01.txt")
def test_rejects_empty(self) -> None:
with pytest.raises(PathValidationError, match="vide"):
validated_prompt_filename("")
|