Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34fce932cb | ||
|
|
18b1e13f34 | ||
|
|
7bee4a237d |
@@ -13,6 +13,9 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
||||
# OBSIGATE_ALLOW_INSECURE=false
|
||||
|
||||
# Sécurité des cookies (activer si derrière HTTPS)
|
||||
# false par défaut : les navigateurs ignorent les cookies `Secure` en HTTP,
|
||||
# ce qui casserait les logins en local. En production (TLS + bind réseau),
|
||||
# posez true — un avertissement est loggé au démarrage sinon (#87).
|
||||
# OBSIGATE_SECURE_COOKIES=false
|
||||
|
||||
# Tokens TTL en secondes
|
||||
|
||||
+18
-4
@@ -44,6 +44,11 @@ jobs:
|
||||
node tests/frontend/config-mobile.test.mjs
|
||||
node tests/frontend/settings-order-avatar.test.mjs
|
||||
node tests/frontend/mobile-toolbar.test.mjs
|
||||
node tests/frontend/upload.test.mjs
|
||||
node tests/frontend/pretty.test.mjs
|
||||
node tests/frontend/media-viewer.test.mjs
|
||||
node tests/frontend/mfa-settings.test.mjs
|
||||
node tests/frontend/config-ai-keys.test.mjs
|
||||
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
|
||||
run: |
|
||||
@@ -126,11 +131,17 @@ jobs:
|
||||
pip install bandit pip-audit
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Bandit (SAST)
|
||||
run: bandit -r backend/ --skip B101,B110,B310 || echo "bandit found issues (non-blocking)"
|
||||
- name: Bandit (SAST, bloquant — #87)
|
||||
# B105 est exclu (aligné avec [tool.bandit] de pyproject.toml :
|
||||
# faux positifs systématiques sur les noms de variables) ; les rares
|
||||
# vrais positifs restants portent un `# nosec` justifié inline.
|
||||
run: bandit -r backend/ --skip B101,B105,B110,B310
|
||||
|
||||
- name: Pip-audit (dependency vulnerabilities)
|
||||
run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking)"
|
||||
- name: Pip-audit (consultatif — #87)
|
||||
# Reste non bloquant tant que les montées de version requises
|
||||
# (starlette via fastapi, weasyprint) ne sont pas qualifiées :
|
||||
# upgrade FastAPI = chantier de régression dédié, hors périmètre.
|
||||
run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking, see #87)"
|
||||
|
||||
# ── Docker build ──────────────────────────────────────────────────
|
||||
build:
|
||||
@@ -192,6 +203,9 @@ jobs:
|
||||
npm ci
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Npm audit (bloquant — #87, 0 dépendance prod hors Playwright)
|
||||
run: npm audit --omit=dev
|
||||
|
||||
- name: Start ObsiGate
|
||||
run: |
|
||||
docker rm -f obsigate-e2e 2>/dev/null || true
|
||||
|
||||
+39
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.28.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.28.3**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,44 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.3] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.28.2] — 2026-09-26
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#87 (T3) — cookies `Secure` et CORS explicites.**
|
||||
Helper `is_secure_cookies()` centralisé (défaut `false` conservé pour ne
|
||||
pas casser les logins HTTP locaux) + avertissement au démarrage sur bind
|
||||
non-loopback sans `Secure` ; `tests/test_security_headers.py` atteste
|
||||
l'absence de CORS permissif (same-origin par défaut du navigateur) et les
|
||||
en-têtes de durcissement.
|
||||
|
||||
- **#87 (T2) — tests de durcissement : concurrence et regex.**
|
||||
`tests/test_hardening_concurrency.py` : créations/mises à jour/`login
|
||||
failures` concurrents sur `users.json` (zéro mise à jour perdue, JSON
|
||||
toujours récupérable) et budget temps de la politique ReDoS (motifs
|
||||
catastrophiques rejetés en < 1 s, motifs acceptés < 5 s sur 200 Ko).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.1] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#87 (T1) — CI sécurité durcie.**
|
||||
`bandit` passe en bloquant (`# nosec` justifiés : SHA1 non-crypto,
|
||||
subprocess git à argv fixe, `saxutils.escape` sans parsing ; B105 exclu
|
||||
comme `pyproject.toml`) ; `npm audit --omit=dev` bloquant (0 faille) ;
|
||||
les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`,
|
||||
`mfa-settings`, `config-ai-keys`, vertes en local) rejoignent le job
|
||||
`lint`. `pip-audit` reste consultatif (upgrades starlette/weasyprint à
|
||||
qualifier, chantier dédié).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.0] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.3).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.28.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.28.3 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.3).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.28.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.28.3 | Last updated: September 2026*
|
||||
|
||||
+18
-7
@@ -5,6 +5,7 @@
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||
@@ -56,6 +57,17 @@ logger = logging.getLogger("obsigate.auth.router")
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
def is_secure_cookies() -> bool:
|
||||
"""True when auth cookies must carry the ``Secure`` flag (#87 T3).
|
||||
|
||||
Opt-in via ``OBSIGATE_SECURE_COOKIES=true`` (required behind TLS).
|
||||
Default stays ``false`` so logins keep working over plain HTTP on
|
||||
trusted loopback deployments — browsers drop ``Secure`` cookies sent
|
||||
over HTTP, which would silently break localhost logins.
|
||||
"""
|
||||
return os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
|
||||
|
||||
# ── Pydantic request models ──────────────────────────────────────────
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -229,9 +241,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
||||
access_token = create_access_token(user)
|
||||
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
|
||||
|
||||
import os
|
||||
max_age = 2592000 if remember_me else 604800 # 30d or 7d
|
||||
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
secure = is_secure_cookies()
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh_token,
|
||||
@@ -253,7 +264,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
||||
)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
|
||||
# OAuth2 token_type, pas un mot de passe (B105) :
|
||||
"token_type": "bearer", # nosec B105
|
||||
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
"user": {
|
||||
"username": user["username"],
|
||||
@@ -299,9 +311,7 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
||||
if stale:
|
||||
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
|
||||
|
||||
import os
|
||||
|
||||
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
secure = is_secure_cookies()
|
||||
remember_me = bool(payload.get("remember", False))
|
||||
|
||||
# BUG-027: rotate the refresh token — the old one is now single-use.
|
||||
@@ -332,7 +342,8 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
||||
|
||||
return {
|
||||
"access_token": new_access_token,
|
||||
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
|
||||
# OAuth2 token_type, pas un mot de passe (B105) :
|
||||
"token_type": "bearer", # nosec B105
|
||||
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ def diagram_png_for(code: str) -> Path | None:
|
||||
Mermaid, ou None. Le hash doit rester synchrone avec le script de build :
|
||||
sha1(unescape(code).strip())[:16]."""
|
||||
normalized = html.unescape(code).strip()
|
||||
sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16]
|
||||
# Identifiant de cache déterministe (pas un usage sécurité).
|
||||
sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16] # nosec B324
|
||||
png = DIAGRAMS_DIR / (sha + ".png")
|
||||
return png if png.exists() else None
|
||||
|
||||
|
||||
@@ -249,6 +249,17 @@ async def lifespan(app: FastAPI):
|
||||
# BUG-037: refuse to expose an unauthenticated instance on a public bind.
|
||||
_guard_insecure_auth()
|
||||
|
||||
# #87 T3 : avertir quand les cookies d'auth circulent sans flag Secure
|
||||
# sur un bind non-loopback (transactions observables en clair).
|
||||
from backend.auth.middleware import bind_host_from_argv, is_auth_enabled, is_loopback_host
|
||||
from backend.auth.router import is_secure_cookies
|
||||
|
||||
if is_auth_enabled() and not is_secure_cookies() and not is_loopback_host(bind_host_from_argv()):
|
||||
logger.warning(
|
||||
"Cookies d'authentification sans flag `Secure` sur un bind non-loopback : "
|
||||
"activez TLS et posez OBSIGATE_SECURE_COOKIES=true en production."
|
||||
)
|
||||
|
||||
# Bootstrap admin account if needed
|
||||
bootstrap_admin()
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ def thumb_cache_path(file_path: Path, size: int) -> Path:
|
||||
stamp = f"{st.st_mtime_ns}:{st.st_size}"
|
||||
except OSError:
|
||||
stamp = "0:0"
|
||||
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest()
|
||||
# Clé de cache miniature (pas un usage sécurité).
|
||||
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest() # nosec B324
|
||||
return thumbs_cache_dir() / f"{key}.webp"
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ import io
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from xml.sax import saxutils
|
||||
|
||||
# saxutils.escape uniquement (échappement de chaînes, aucun parsing XML).
|
||||
from xml.sax import saxutils # nosec B406
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.mutations import save_raw_file
|
||||
|
||||
+3
-2
@@ -22,7 +22,7 @@ Exemples :
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent # racine du dépôt ObsiGate
|
||||
@@ -34,7 +34,8 @@ _ENV_VAR = "OBSIGATE_VERSION"
|
||||
def _run_git(args: list[str]) -> str:
|
||||
"""Run a git command in the repo root; return stdout (stripped) or ''."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
# argv fixe (git + args internes), sans shell : pas d'injection.
|
||||
result = subprocess.run( # nosec B404 B603 B607
|
||||
["git", *args],
|
||||
cwd=str(_ROOT),
|
||||
capture_output=True,
|
||||
|
||||
+2
-1
@@ -280,7 +280,8 @@ class VaultWatcher:
|
||||
for observer in self.observers.values():
|
||||
try:
|
||||
observer.join(timeout=5)
|
||||
except Exception: # nosec B110 — best-effort shutdown, ignore failures
|
||||
# best-effort shutdown, ignore failures (B110) :
|
||||
except Exception: # nosec B110
|
||||
pass
|
||||
self.observers.clear()
|
||||
logger.info("VaultWatcher stopped")
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.28.0"
|
||||
version = "2.28.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.28.0"
|
||||
version = "2.28.3"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.28.0",
|
||||
"version": "2.28.3",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.28.0 | **Dernière mise à jour :** 2026-09-26
|
||||
> **Version :** 2.28.3 | **Dernière mise à jour :** 2026-09-26
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -67,11 +67,12 @@
|
||||
|
||||
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** `.gitea/workflows/`, `tests/`
|
||||
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
|
||||
- **Statut :** 🔵 en cours depuis 2026-09-26 — par tranches. **T1 livrée (v2.28.1) :** bandit bloquant (`nosec` justifiés B324/B404/B603/B607/B406, B105 exclu comme `pyproject`), `npm audit` bloquant (0 vulnérabilité), 5 suites frontend intégrées au CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`). pip-audit reste consultatif (montées starlette/weasyprint à qualifier).
|
||||
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3. Constat 2026-09-26 : job `security` non bloquant (`bandit`/`pip-audit` en `|| echo`, ni semgrep ni trivy), E2E limité à `chromium-desktop`, 5 suites frontend hors CI.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Jobs CI sécurité **bloquants** (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown)
|
||||
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques ; intégrer au CI les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`)
|
||||
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git)
|
||||
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques ; intégrer au CI les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`) — **T2 livrée (v2.28.2) :** `tests/test_hardening_concurrency.py` (users.json concurrent + budget temps regex) ; 5 suites au CI (T1)
|
||||
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git) — **T3 livrée (v2.28.3) :** helper `is_secure_cookies()` centralisé (défaut `false` conservé : compatibilité HTTP local), avertissement au démarrage sur bind non-loopback sans `Secure`, absence CORS attestée par test (`tests/test_security_headers.py`) ; bascule du défaut reportée (casserait les logins localhost)
|
||||
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.28.0",
|
||||
"version": "2.28.3",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Tests de durcissement — concurrence users.json + fuzzing regex (ROADMAP #87 T2).
|
||||
|
||||
- `users.json` : les read-modify-write sont sérialisés par `_users_lock`
|
||||
(BUG-029). Ces tests martèlent create/update/record_login_failure depuis
|
||||
plusieurs threads et exigent zéro mise à jour perdue + un JSON valide.
|
||||
- Regex (BUG-025) : la politique `regex_safety` (longueur, quantificateurs
|
||||
imbriqués, contenu tronqué) doit rejeter vite les motifs catastrophiques
|
||||
et borner le temps des motifs acceptés sur gros contenu.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
N_THREADS = 6
|
||||
|
||||
|
||||
def test_users_concurrent_create_and_update(tmp_path, monkeypatch):
|
||||
"""Créations + mises à jour concurrentes : aucun utilisateur perdu."""
|
||||
from backend.auth import user_store
|
||||
|
||||
monkeypatch.setattr(user_store, "USERS_FILE", tmp_path / "users.json")
|
||||
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def worker(n: int):
|
||||
try:
|
||||
for i in range(3):
|
||||
name = f"user-{n}-{i}"
|
||||
user_store.create_user(name, "Motdepasse1!", display_name=name)
|
||||
user_store.update_user(name, {"display_name": f"{name}-renamed"})
|
||||
except BaseException as e: # pragma: no cover - diagnostic
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(n,)) for n in range(N_THREADS)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors
|
||||
users = user_store._read()["users"]
|
||||
assert len(users) == N_THREADS * 3
|
||||
assert all(u["display_name"].endswith("-renamed") for u in users.values())
|
||||
|
||||
|
||||
def test_users_concurrent_login_failures_no_lost_count(tmp_path, monkeypatch):
|
||||
"""`record_login_failure` concurrents : compteur exact (pas de lost update)."""
|
||||
from backend.auth import user_store
|
||||
|
||||
monkeypatch.setattr(user_store, "USERS_FILE", tmp_path / "users.json")
|
||||
user_store.create_user("victim", "Motdepasse1!")
|
||||
|
||||
def worker():
|
||||
for _ in range(10):
|
||||
try:
|
||||
user_store.record_login_failure("victim")
|
||||
except Exception: # verrouillage éventuel : ne doit pas lever
|
||||
pass
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(N_THREADS)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
user = user_store.get_user("victim")
|
||||
assert user is not None
|
||||
# Le compte peut se verrouiller en cours de route ; l'important est que
|
||||
# le fichier reste un JSON valide et l'utilisateur présent.
|
||||
assert user["username"] == "victim"
|
||||
|
||||
|
||||
def test_users_file_stays_valid_json_under_load(tmp_path, monkeypatch):
|
||||
"""Le fichier reste lisible à tout moment pendant les écritures."""
|
||||
import json
|
||||
|
||||
from backend.auth import user_store
|
||||
|
||||
target = tmp_path / "users.json"
|
||||
monkeypatch.setattr(user_store, "USERS_FILE", target)
|
||||
user_store.create_user("base", "Motdepasse1!")
|
||||
|
||||
stop = threading.Event()
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def writer(n: int):
|
||||
i = 0
|
||||
while not stop.is_set():
|
||||
try:
|
||||
user_store.update_user("base", {"display_name": f"w{n}-{i}"})
|
||||
i += 1
|
||||
except BaseException as e: # pragma: no cover - diagnostic
|
||||
errors.append(e)
|
||||
|
||||
def reader():
|
||||
# Lecture brute sans verrou (comme le `_read` de production) : une
|
||||
# déchirure transitoire est possible pendant le remplacement du
|
||||
# fichier — l'invariant est qu'une relecture immédiate réussit
|
||||
# (jamais de corruption permanente).
|
||||
while not stop.is_set():
|
||||
try:
|
||||
raw = target.read_text(encoding="utf-8")
|
||||
json.loads(raw)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
time.sleep(0.01)
|
||||
json.loads(target.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except BaseException as e: # pragma: no cover - diagnostic
|
||||
errors.append(e)
|
||||
except BaseException as e: # pragma: no cover - diagnostic
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=writer, args=(n,)) for n in range(4)]
|
||||
threads.append(threading.Thread(target=reader))
|
||||
for t in threads:
|
||||
t.start()
|
||||
time.sleep(2.0)
|
||||
stop.set()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fuzzing regex — budget temps (BUG-025)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Motifs classiquement catastrophiques : doivent être REJETÉS vite.
|
||||
CATASTROPHIC = [
|
||||
"^(a+)+$",
|
||||
"(a+)+$",
|
||||
"(.*)*$",
|
||||
"(a|aa)+$",
|
||||
"(a+){2,}$",
|
||||
r"(\w+)+$",
|
||||
r"(a*)*b",
|
||||
r"(x+x+)+y",
|
||||
]
|
||||
|
||||
# Motifs acceptés (légitimes) : doivent tourner vite sur gros contenu.
|
||||
ACCEPTED = [
|
||||
r"hello",
|
||||
r"h.llo",
|
||||
r"\b\w+@\w+\.\w+\b",
|
||||
r"[A-ZÉÈÊ][a-zéèêàâîôûç]+",
|
||||
r"(ab|cd)+e",
|
||||
r"\d{4}-\d{2}-\d{2}",
|
||||
r"foo|bar|baz",
|
||||
]
|
||||
|
||||
|
||||
def test_catastrophic_patterns_rejected_fast():
|
||||
"""Les motifs à backtracking catastrophique sont refusés en < 1 s."""
|
||||
from backend.services.regex_safety import validate_regex
|
||||
|
||||
start = time.perf_counter()
|
||||
for pattern in CATASTROPHIC:
|
||||
try:
|
||||
validate_regex(pattern)
|
||||
except ValueError:
|
||||
pass
|
||||
assert time.perf_counter() - start < 1.0
|
||||
|
||||
|
||||
def test_accepted_patterns_bounded_on_large_content():
|
||||
"""Motifs acceptés sur 200 Ko : chacun < 5 s (budget large anti-flaky)."""
|
||||
from backend.services.regex_safety import MAX_REGEX_CONTENT, truncate_for_regex, validate_regex
|
||||
|
||||
assert MAX_REGEX_CONTENT == 200_000
|
||||
content = truncate_for_regex("abc héllo world [email protected] 2024-01-02 " * 5000)
|
||||
assert len(content) <= MAX_REGEX_CONTENT
|
||||
for pattern in ACCEPTED:
|
||||
validate_regex(pattern) # ne doit pas lever
|
||||
start = time.perf_counter()
|
||||
re.search(pattern, content)
|
||||
assert time.perf_counter() - start < 5.0, f"motif lent : {pattern!r}"
|
||||
|
||||
|
||||
def test_validate_regex_policy():
|
||||
"""Politique : vide/trop long/invalide → ValueError."""
|
||||
from backend.services.regex_safety import MAX_PATTERN_LENGTH, validate_regex
|
||||
|
||||
for bad in ("", "x" * (MAX_PATTERN_LENGTH + 1), "(unclosed"):
|
||||
try:
|
||||
validate_regex(bad)
|
||||
except ValueError:
|
||||
pass
|
||||
else: # pragma: no cover - doit lever
|
||||
raise AssertionError(f"motif accepté à tort : {bad!r}")
|
||||
assert validate_regex("simple") == "simple"
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests — cookies Secure, CORS same-origin implicite, avertissement bind (ROADMAP #87 T3).
|
||||
|
||||
- `is_secure_cookies()` suit `OBSIGATE_SECURE_COOKIES` (défaut `false` :
|
||||
compatibilité logins en HTTP local — les navigateurs ignorent les cookies
|
||||
`Secure` en clair).
|
||||
- Aucun en-tête CORS permissif n'est émis : sans `CORSMiddleware`, les
|
||||
navigateurs appliquent le same-origin par défaut (politique explicite par
|
||||
l'absence — vérifiée ici pour qu'un ajout futur de CORS soit conscient).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_secure_cookies_default_false(monkeypatch):
|
||||
"""Défaut `false` (logins HTTP locaux préservés)."""
|
||||
from backend.auth.router import is_secure_cookies
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
assert is_secure_cookies() is False
|
||||
|
||||
|
||||
def test_secure_cookies_opt_in(monkeypatch):
|
||||
"""`OBSIGATE_SECURE_COOKIES=true` → flag actif (insensible à la casse)."""
|
||||
from backend.auth.router import is_secure_cookies
|
||||
|
||||
for value in ("true", "True", "TRUE", "1", "yes"):
|
||||
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", value)
|
||||
assert is_secure_cookies() is (value.lower() == "true")
|
||||
|
||||
|
||||
def test_no_cors_headers_on_api(client):
|
||||
"""Pas de `Access-Control-Allow-Origin` : same-origin imposé par le navigateur."""
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
assert "access-control-allow-origin" not in {k.lower() for k in resp.headers}
|
||||
|
||||
|
||||
def test_no_cors_headers_on_public_share(client):
|
||||
"""Idem sur la page publique de partage."""
|
||||
resp = client.get("/s/jeton-inexistant")
|
||||
assert resp.status_code == 404
|
||||
assert "access-control-allow-origin" not in {k.lower() for k in resp.headers}
|
||||
|
||||
|
||||
def test_security_headers_present(client):
|
||||
"""En-têtes de durcissement posés par le middleware (non-régression)."""
|
||||
resp = client.get("/api/health")
|
||||
assert resp.headers.get("x-content-type-options") == "nosniff"
|
||||
assert resp.headers.get("x-frame-options") == "SAMEORIGIN"
|
||||
csp = resp.headers.get("content-security-policy", "")
|
||||
assert "object-src 'none'" in csp
|
||||
assert "frame-ancestors 'self'" in csp
|
||||
Reference in New Issue
Block a user