Files
openmind/backend/tests/test_link_privacy.py
snoopy 9ede2c5e68 Phase 2.1.1 : durcissement avant exposition publique
Fail-fast sur la configuration. Une limite de debit illisible (10/hours,
10/banana, -1/hour) arrete desormais le demarrage avec un message nommant la
variable, au lieu de desactiver silencieusement la protection : seules les
valeurs 0, off, none, disabled et vide desactivent volontairement. Meme
traitement pour MAX_SHARED_MAPS, MAX_SHARE_SIZE_MB, DEFAULT_SHARE_EXPIRY_DAYS
et chaque entree de TRUSTED_PROXY_CIDRS.

X-Forwarded-For n'est plus cru sur la seule foi de TRUST_PROXY_HEADERS : la
connexion doit aussi provenir d'un reseau declare dans TRUSTED_PROXY_CIDRS
(ipaddress, IPv4/IPv6/CIDR). Activer la confiance sans declarer de reseau est
refuse au demarrage, faute de quoi la configuration semblerait active sans
l'etre. L'adresse retenue est normalisee : les differentes ecritures d'une meme
IPv6 ne donnent plus des compteurs distincts.

MAX_SHARED_MAPS : comptage et insertion serialises par un verrou de processus,
pour que deux creations simultanees ne franchissent pas ensemble la derniere
place. Documente comme valable pour un service applicatif unique.

Ajoute APP_BIND pour ne pas publier 8080 sur toutes les interfaces en
production, une option backup --keep pour la retention, et documente Nginx,
Traefik en override, cron et systemd.

Corrige un bug de rendu du build local : mon edition avait reecrit
docker-entrypoint.sh en CRLF, rendant le shebang introuvable dans le conteneur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:53:29 +02:00

193 lines
7.5 KiB
Python

"""Protection des liens non repertories : indexation, referrer, journaux."""
from __future__ import annotations
import logging
from fastapi.testclient import TestClient
from app.logging_config import TokenRedactingFilter, redact_tokens
from app.services.tokens import SHARE_TOKEN_BYTES, generate_share_token
from tests.conftest import build_map
def test_robots_txt_disallows_shares(client: TestClient, tmp_path) -> None:
"""Le fichier livre au frontend interdit /s/ et /api/."""
robots = (
__import__("pathlib").Path(__file__).resolve().parents[2]
/ "frontend"
/ "public"
/ "robots.txt"
)
content = robots.read_text(encoding="utf-8")
assert "Disallow: /s/" in content
assert "Disallow: /api/" in content
def test_share_routes_are_marked_noindex(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
public_page = client.get(f"/s/{created['share_token']}")
assert public_page.headers["X-Robots-Tag"] == "noindex, nofollow, noarchive"
api = client.get(f"/api/shares/{created['share_token']}")
assert api.headers["X-Robots-Tag"] == "noindex, nofollow, noarchive"
def test_dashboard_is_not_marked_noindex(client: TestClient) -> None:
assert "X-Robots-Tag" not in client.get("/").headers
def test_referrer_policy_is_no_referrer(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
for path in ("/", f"/s/{created['share_token']}", "/api/health"):
assert client.get(path).headers["Referrer-Policy"] == "no-referrer"
def test_security_headers_are_still_present(client: TestClient) -> None:
headers = client.get("/").headers
assert headers["X-Content-Type-Options"] == "nosniff"
assert "frame-ancestors 'none'" in headers["Content-Security-Policy"]
assert headers["X-Frame-Options"] == "DENY"
assert "geolocation=()" in headers["Permissions-Policy"]
def test_share_tokens_have_at_least_128_bits() -> None:
assert SHARE_TOKEN_BYTES * 8 >= 128
token = generate_share_token()
assert len(token) >= 22
assert len({generate_share_token() for _ in range(200)}) == 200
def test_token_redaction_in_paths() -> None:
assert redact_tokens("GET /api/shares/a8F3xP9kQwZ2 HTTP/1.1") == (
"GET /api/shares/[REDACTED] HTTP/1.1"
)
assert redact_tokens("GET /s/OZGrnetO4RPbqPtS HTTP/1.1") == "GET /s/[REDACTED] HTTP/1.1"
# Les autres routes restent lisibles pour le diagnostic.
assert redact_tokens("GET /api/health HTTP/1.1") == "GET /api/health HTTP/1.1"
assert redact_tokens("GET /api/config HTTP/1.1") == "GET /api/config HTTP/1.1"
def test_redacting_filter_rewrites_arguments() -> None:
record = logging.LogRecord(
name="uvicorn.access",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg='%s - "%s" %d',
args=("1.2.3.4", "GET /api/shares/SECRETTOKEN12 HTTP/1.1", 200),
exc_info=None,
)
TokenRedactingFilter().filter(record)
assert "SECRETTOKEN12" not in record.getMessage()
assert "[REDACTED]" in record.getMessage()
def test_management_token_never_appears_in_logs(client: TestClient, caplog) -> None:
with caplog.at_level(logging.DEBUG):
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
token = created["management_token"]
client.put(
f"/api/shares/{created['share_token']}",
json={"title": "y", "map": build_map()},
headers={"X-Management-Token": token},
)
client.put(
f"/api/shares/{created['share_token']}",
json={"title": "z", "map": build_map()},
headers={"X-Management-Token": "mauvais"},
)
# `httpx`, utilise par le client de test, journalise l'URL qu'il appelle :
# c'est un log de l'outillage de test, pas de l'application. On verifie donc
# uniquement ce qu'OpenMind ecrit lui-meme.
logged = "\n".join(
record.getMessage()
for record in caplog.records
if not record.name.startswith(("httpx", "httpcore"))
)
assert token not in logged
assert created["share_token"] not in logged
# Les messages applicatifs designent un partage par une empreinte courte.
assert "share=" in logged
def test_no_token_leaks_on_any_management_route(client: TestClient, caplog) -> None:
"""PATCH, DELETE et verify manipulent aussi les deux jetons."""
with caplog.at_level(logging.DEBUG):
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
token = created["share_token"]
management = created["management_token"]
headers = {"X-Management-Token": management}
client.patch(f"/api/shares/{token}", json={"expires_in_days": 7}, headers=headers)
client.post(f"/api/shares/{token}/verify", headers=headers)
client.post(f"/api/shares/{token}/verify", headers={"X-Management-Token": "faux"})
client.patch(
f"/api/shares/{token}", json={"permanent": True}, headers={"X-Management-Token": "faux"}
)
client.delete(f"/api/shares/{token}", headers=headers)
logged = "\n".join(
record.getMessage()
for record in caplog.records
if not record.name.startswith(("httpx", "httpcore"))
)
assert management not in logged
assert token not in logged
assert "share=" in logged
def test_access_log_redaction_covers_every_share_route() -> None:
"""Les chemins journalises par uvicorn ne doivent porter aucun jeton."""
token = "sbcY5FwVInYtTSGLNjBCyw"
for method, path in [
("GET", f"/api/shares/{token}"),
("PUT", f"/api/shares/{token}"),
("PATCH", f"/api/shares/{token}"),
("DELETE", f"/api/shares/{token}"),
("POST", f"/api/shares/{token}/verify"),
("GET", f"/s/{token}"),
]:
line = redact_tokens(f"{method} {path} HTTP/1.1")
assert token not in line, line
assert "[REDACTED]" in line
def test_application_logging_survives_migrations(client: TestClient, caplog) -> None:
"""Les migrations ne doivent pas desactiver les loggers de l'application.
Alembic reconfigure les logs quand il est lance en ligne de commande. Lance
dans le processus applicatif, il couperait les journaux d'acces d'uvicorn :
la configuration embarquee doit donc laisser les loggers en place.
"""
from app.db.migrations import run_migrations
from app.db.session import engine
access_logger = logging.getLogger("uvicorn.access")
run_migrations(engine)
assert access_logger.disabled is False
with caplog.at_level(logging.INFO):
access_logger.info('%s - "%s" %d', "1.2.3.4", "GET /api/health HTTP/1.1", 200)
assert any("GET /api/health" in record.getMessage() for record in caplog.records)
def test_entry_points_are_never_cached_long() -> None:
"""index.html, sw.js et le manifeste ne doivent jamais figer une version.
Sans cela, une mise a jour de l'application resterait invisible tant que le
navigateur garde l'ancienne page en cache.
"""
from app.web import _NO_CACHE_FILES
assert {"index.html", "sw.js", "manifest.webmanifest"} <= _NO_CACHE_FILES
def test_hashed_assets_get_a_long_cache(client: TestClient) -> None:
response = client.get("/assets/index-abc123.js")
# Le fichier n'existe pas dans l'environnement de test, mais l'en-tete de
# cache est pose par le middleware, avant le service du fichier.
assert "immutable" in response.headers.get("Cache-Control", "")