test: #87 T2 durcissement concurrence users.json et budget temps regex

This commit is contained in:
2026-09-26 18:34:06 -04:00
parent 7bee4a237d
commit 18b1e13f34
10 changed files with 224 additions and 14 deletions
+13 -1
View File
@@ -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.1**.
> [Unreleased](#unreleased). La dernière version livrée est **2.28.2**.
---
@@ -14,6 +14,18 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.28.2] — 2026-09-26
### Ajouté
- **#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é
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.28.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.28.2-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](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.1).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.2).
---
*Projet : ObsiGate | Version : 2.28.1 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.28.2 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.28.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.28.2-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](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.1).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.2).
---
*Project: ObsiGate | Version: 2.28.1 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.28.2 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.28.1
2.28.2
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.28.1"
version = "2.28.2"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.28.1"
version = "2.28.2"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.28.1",
"version": "2.28.2",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+2 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.28.1 | **Dernière mise à jour :** 2026-09-26
> **Version :** 2.28.2 | **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)**
@@ -71,7 +71,7 @@
- **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`)
- [ ] 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)
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.28.1",
"version": "2.28.2",
"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": {
+198
View File
@@ -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"