diff --git a/CHANGELOG.md b/CHANGELOG.md index f2c198c..5ee640d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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é diff --git a/README.fr.md b/README.fr.md index f8192d8..b2cf2a7 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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* diff --git a/README.md b/README.md index bc9323d..4c33f29 100644 --- a/README.md +++ b/README.md @@ -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* diff --git a/VERSION b/VERSION index 9738a24..0bd6cbc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.28.1 +2.28.2 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index bf5fcf1..94bcbfc 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.28.1" +version = "2.28.2" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 5f9280a..eb68f5e 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -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" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index d376dbf..2606d93 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -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", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0f17059..da50738 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/package.json b/package.json index f6bb9fb..a465a24 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/tests/test_hardening_concurrency.py b/tests/test_hardening_concurrency.py new file mode 100644 index 0000000..9a969d8 --- /dev/null +++ b/tests/test_hardening_concurrency.py @@ -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 foo@example.com 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"