diff --git a/.env.example b/.env.example index 136d478..575d788 100644 --- a/.env.example +++ b/.env.example @@ -12,11 +12,12 @@ OBSIGATE_ADMIN_PASSWORD=chab30 # (ex. 0.0.0.0) sauf si l'on force l'opt-in ci-dessous. À réserver au local. # 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 +# Sécurité des cookies : true|false|auto (défaut : auto — Secure si la +# requête arrive en https, sinon pas de flag ; les navigateurs ignorent les +# cookies `Secure` en HTTP, ce qui casserait les logins en local). +# Derrière un reverse proxy qui termine TLS, auto suffit avec +# OBSIGATE_TRUST_PROXY=true (X-Forwarded-Proto honoré). +# OBSIGATE_SECURE_COOKIES=auto # Tokens TTL en secondes # OBSIGATE_ACCESS_TOKEN_TTL=31536000000 # 1000 ans diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index daa5de0..f0f1768 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -130,7 +130,7 @@ jobs: - name: Install dependencies run: | - pip install bandit pip-audit + pip install bandit pip-audit semgrep pip install -r backend/requirements.txt - name: Bandit (SAST, bloquant — #87) @@ -139,14 +139,21 @@ jobs: # vrais positifs restants portent un `# nosec` justifié inline. run: bandit -r backend/ --skip B101,B105,B110,B310 - - 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. - # NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:` - # ci-dessous (tronqué au premier `#`, même entre guillemets, ce qui - # cassait la citation de l'echo) — la réf #87 ne vit qu'ici. - run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking)" + - name: Semgrep (SAST local, bloquant — #87) + # Règles 100 % locales (semgrep-rules/, 8 règles) : aucun + # téléchargement de registre (runner au réseau fragile). + run: semgrep --config semgrep-rules/ backend/ + + - name: Pip-audit (bloquant — #87) + # Bloquant depuis T6 (#87) : dépendances qualifiées (mistune 3.3.3, + # python-multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + # + starlette 1.7.0, setuptools 84 — suite complète verte + 0 vuln). + # Seule exception documentée : PYSEC-2026-1325 (ecdsa, Minerva) — + # aucun correctif upstream ET ObsiGate ne signe/vérifie qu'en HS256 + # (backend/auth/jwt_handler.py), les chemins ECDSA P-256 ne + # s'exécutent jamais. + # NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`. + run: pip-audit --ignore-vuln PYSEC-2026-1325 # ── Docker build ────────────────────────────────────────────────── build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 66204c2..f5c5ff8 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.14**. +> [Unreleased](#unreleased). La dernière version livrée est **2.28.15**. --- @@ -14,6 +14,39 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.28.15] — 2026-09-27 + +### Sécurité + +- **#87 T6 — dépendances qualifiées, `pip-audit` bloquant (0 vulnérabilité).** + mistune 3.0.2 → 3.3.3 (XSS/ReDoS/DoS dans le moteur de rendu), + python-multipart 0.0.9 → 0.0.31, weasyprint 69 → 70, mcp 1.9.4 → 1.28.1, + fastapi 0.110.3 → 0.141.1 + starlette 0.37.2 → 1.7.0, setuptools 84 ; + `cast(str, …)` aux 3 sites d'appel mistune (typage 3.3 resserré). Suite + complète 1359 passed, ruff/mypy 0. Seule exception : PYSEC-2026-1325 + (ecdsa, Minerva) — aucun correctif upstream ET JWT exclusivement HS256 + (`backend/auth/jwt_handler.py`), les chemins ECDSA P-256 ne s'exécutent + jamais → `--ignore-vuln` documenté. + +- **#87 T7 — semgrep SAST local bloquant (8 règles, 0 finding).** + Ruleset `semgrep-rules/` (eval/exec, shell=True, os.system, pickle, + yaml.load sans Loader, verify=False, Markup, mktemp) — 100 % local, + aucun registre réseau (runner au réseau fragile). Trivy écarté : + binaire + base de vulnérabilités à télécharger à chaque run, couche + Python déjà couverte par `pip-audit` bloquant (image = slim + 4 libs). + +- **#87 T8 — fin BUG-034 : cookies Secure auto, CORS same-origin explicite.** + `OBSIGATE_SECURE_COOKIES=true|false|auto` (défaut auto : Secure en https, + sinon rien — logins http locaux préservés ; `X-Forwarded-Proto` honoré + sous `TRUST_PROXY`, avertissement démarrage affiné, `TRUST_PROXY=true` + dans le compose prod) ; `CORSMiddleware` same-origin explicite (sûr : + web et desktop Tauri same-origin, API directe hors navigateur) ; + `style-src 'unsafe-inline'` conservé et assumé (189 attributs `style=` + + 343 `el.style` — suppression = réécriture complète, risque nul côté + exécution une fois `script-src` verrouillé en T5c). + +--- + ## [2.28.14] — 2026-09-27 --- diff --git a/README.fr.md b/README.fr.md index e38016f..eeaff45 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.14-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.28.15-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.14). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.15). --- -*Projet : ObsiGate | Version : 2.28.14 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.28.15 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 558040d..15a0ca5 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.14-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.28.15-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.14). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.15). --- -*Project: ObsiGate | Version: 2.28.14 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.28.15 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 3ebae6e..895dc2e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.28.14 +2.28.15 diff --git a/backend/auth/router.py b/backend/auth/router.py index 761ba60..3ff96e3 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -16,7 +16,7 @@ from backend.ratelimit import record_account_failure as rl_record_account_failur from backend.ratelimit import record_account_success as rl_record_account_success from backend.ratelimit import record_failure as rl_record_failure from backend.ratelimit import record_success as rl_record_success -from backend.services.net import get_client_ip +from backend.services.net import get_client_ip, is_trusted_proxy from .jwt_handler import ( ACCESS_TOKEN_EXPIRE_SECONDS, @@ -57,15 +57,32 @@ 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). +def is_secure_cookies(request: Request | None = None) -> bool: + """True when auth cookies must carry the ``Secure`` flag (#87 T3/T8). - 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. + ``OBSIGATE_SECURE_COOKIES=true|false|auto`` (défaut : ``auto``) : + ``true``/``false`` forcent le comportement ; ``auto`` met ``Secure`` + si la requête arrive en https (production derrière TLS) et l'omet + sinon (dev local en http — les navigateurs jettent les cookies + ``Secure`` sur http, ce qui casserait silencieusement les logins + localhost). Derrière un reverse proxy qui termine TLS, le schéma perçu + est http : avec ``OBSIGATE_TRUST_PROXY=true``, ``X-Forwarded-Proto`` + est honoré (même garde que ``get_client_ip``, BUG-030). """ - return os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true" + forced = os.environ.get("OBSIGATE_SECURE_COOKIES", "auto").lower() + if forced in ("1", "true", "yes", "on"): + return True + if forced in ("0", "false", "no", "off"): + return False + if request is None: + return False + if request.url.scheme == "https": + return True + if is_trusted_proxy(): + proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower() + if proto == "https": + return True + return False # ── Pydantic request models ────────────────────────────────────────── @@ -230,10 +247,11 @@ async def login(body: LoginRequest, response: Response, request: Request): "remember_me": body.remember_me, } - return _issue_tokens(user, body.username, body.remember_me, response) + return _issue_tokens(user, body.username, body.remember_me, response, request) -def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict: +def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response, + request: Request | None = None) -> dict: """Issue JWT tokens after successful authentication (password or MFA verified).""" record_login_success(username) rl_record_account_success(username) @@ -242,7 +260,7 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me) max_age = 2592000 if remember_me else 604800 # 30d or 7d - secure = is_secure_cookies() + secure = is_secure_cookies(request) response.set_cookie( key="refresh_token", value=refresh_token, @@ -311,7 +329,7 @@ async def refresh_token_endpoint(request: Request, response: Response): if stale: raise HTTPException(401, "Session expirée, veuillez vous reconnecter") - secure = is_secure_cookies() + secure = is_secure_cookies(request) remember_me = bool(payload.get("remember", False)) # BUG-027: rotate the refresh token — the old one is now single-use. @@ -437,6 +455,7 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)): async def change_password( req: ChangePasswordRequest, response: Response, + request: Request, current_user=Depends(require_auth), ): """Change own password. @@ -452,7 +471,7 @@ async def change_password( updated = get_user(current_user["username"]) result: dict = {"message": "Mot de passe mis à jour"} if updated is not None: - result.update(_issue_tokens(updated, updated["username"], False, response)) + result.update(_issue_tokens(updated, updated["username"], False, response, request)) return result @@ -815,7 +834,7 @@ async def mfa_webauthn_verify( rl_record_success(client_ip) logger.info(f"User '{body.username}' logged in via WebAuthn") - return _issue_tokens(user, body.username, body.remember_me, response) + return _issue_tokens(user, body.username, body.remember_me, response, request) @router.get("/mfa/status") @@ -868,7 +887,7 @@ async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: R # Clear IP rate limit on success rl_record_success(client_ip) - return _issue_tokens(user, body.username, body.remember_me, response) + return _issue_tokens(user, body.username, body.remember_me, response, request) @router.post("/mfa/recovery") @@ -906,7 +925,7 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque rl_record_success(client_ip) logger.info(f"User '{body.username}' logged in via recovery code") - return _issue_tokens(user, body.username, False, response) + return _issue_tokens(user, body.username, False, response, request) # ── Admin endpoints ─────────────────────────────────────────────────── diff --git a/backend/export.py b/backend/export.py index 720cbf2..01eec00 100644 --- a/backend/export.py +++ b/backend/export.py @@ -23,6 +23,7 @@ import re import unicodedata import zipfile from pathlib import Path +from typing import cast import frontmatter import mistune @@ -246,7 +247,9 @@ def _render_body(md: str, file_dir: Path, vault_path: Path, current: Path) -> st """Render raw markdown to an HTML fragment (images inlined, wikilinks resolved).""" md = _inline_images(md, file_dir, vault_path) md = _convert_wikilinks(md, vault_path, current) - return _markdown(md) + # mistune 3.3 types `Markdown.__call__` as `str | list[...]` (le renderer + # HTML renvoie toujours `str` à l'exécution). + return cast(str, _markdown(md)) def _build_nav(vault_path: Path, current: Path) -> str: diff --git a/backend/main.py b/backend/main.py index 0bfb9cf..fcc3475 100644 --- a/backend/main.py +++ b/backend/main.py @@ -260,12 +260,19 @@ 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). + # #87 T3/T8 : avertir quand les cookies d'auth circulent sans flag Secure + # sur un bind non-loopback (transactions observables en clair). Avec + # `OBSIGATE_SECURE_COOKIES=auto` (défaut) + `OBSIGATE_TRUST_PROXY=true`, + # le flag suit `X-Forwarded-Proto` : pas d'avertissement, le https du + # reverse proxy est honoré. from backend.auth.middleware import bind_host_from_argv, is_auth_enabled, is_loopback_host from backend.auth.router import is_secure_cookies + from backend.services.net import is_trusted_proxy - if is_auth_enabled() and not is_secure_cookies() and not is_loopback_host(bind_host_from_argv()): + secure_mode = os.environ.get("OBSIGATE_SECURE_COOKIES", "auto").lower() + proxy_secure = secure_mode == "auto" and is_trusted_proxy() + if (is_auth_enabled() and not is_secure_cookies() + and not is_loopback_host(bind_host_from_argv()) and not proxy_secure): logger.warning( "Cookies d'authentification sans flag `Secure` sur un bind non-loopback : " "activez TLS et posez OBSIGATE_SECURE_COOKIES=true en production." @@ -388,6 +395,7 @@ async def _service_error_handler(request: Request, exc: ServiceError): # GZip compression — reduces bandwidth by ~70% for text responses # Custom wrapper: skip compression for SSE streams (/api/events) from fastapi.middleware.gzip import GZipMiddleware +from starlette.middleware.cors import CORSMiddleware from starlette.types import Receive, Scope, Send @@ -418,6 +426,22 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000) # Security headers on all responses app.add_middleware(SecurityHeadersMiddleware) +# Explicit same-origin CORS policy (#87 T8 — finit BUG-034). +# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`, +# donc toute lecture cross-origin est refusée (défense explicite, plus +# seulement l'absence de middleware). Sûr pour tous les clients : web +# (same-origin), desktop Tauri (la webview est redirigée same-origin sur +# http://127.0.0.1:, voir frontend/js/desktop.js) et API directe +# (curl/scripts, CORS non appliqué hors navigateur). Ajouté en dernier : +# le plus externe, les preflights court-circuitent avant tout le reste. +app.add_middleware( + CORSMiddleware, + allow_origins=[], + allow_credentials=False, + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["*"], +) + # Auth router # Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c). from backend.ai_routes import router as ai_router diff --git a/backend/render.py b/backend/render.py index 00a0d76..9031f76 100644 --- a/backend/render.py +++ b/backend/render.py @@ -15,6 +15,7 @@ import html as html_mod import re import unicodedata from pathlib import Path +from typing import cast import mistune @@ -196,7 +197,9 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non # Normalize line breaks to match Obsidian behavior (single \n → hard break) converted = _normalize_line_breaks(converted) - rendered = _markdown_renderer(converted) + # mistune 3.3 types `Markdown.__call__` as `str | list[...]` (les + # renderers HTML renvoient toujours `str` à l'exécution). + rendered = cast(str, _markdown_renderer(converted)) # Add heading IDs for TOC navigation rendered = _add_heading_ids(rendered) diff --git a/backend/requirements.txt b/backend/requirements.txt index c2dd631..400fac2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,9 +1,9 @@ -fastapi==0.110.3 -uvicorn==0.30.0 +fastapi==0.141.1 +uvicorn==0.54.0 websockets>=12.0 python-frontmatter==1.1.0 -mistune==3.0.2 -python-multipart==0.0.9 +mistune==3.3.3 +python-multipart==0.0.31 aiofiles==23.2.1 aiohttp>=3.9.0 watchdog>=4.0.0 @@ -11,7 +11,7 @@ argon2-cffi>=23.1.0 python-jose>=3.3.0 sortedcontainers>=2.4.0 snowballstemmer>=2.2.0 -weasyprint>=60.0 +weasyprint>=70.0 httpx>=0.27.0 pypdf>=4.0 pyotp>=2.10.0 @@ -19,7 +19,7 @@ segno>=1.5.0 webauthn==2.6.0 psutil>=5.9 pywebpush>=2.3.0 -mcp==1.9.4 +mcp==1.28.1 sse-starlette==2.1.3 openpyxl>=3.1 python-docx>=1.1 diff --git a/backend/tools/documents.py b/backend/tools/documents.py index 7d85da6..24abf4f 100644 --- a/backend/tools/documents.py +++ b/backend/tools/documents.py @@ -18,7 +18,7 @@ import csv as csv_lib import io import logging import re -from typing import Any +from typing import Any, cast # saxutils.escape uniquement (échappement de chaînes, aucun parsing XML). from xml.sax import saxutils # nosec B406 @@ -173,7 +173,9 @@ def _render_markdown_pdf(content: str, title: str) -> bytes | None: escape=False, plugins=["table", "strikethrough", "footnotes", "task_lists"], ) - html = renderer(content) + # mistune 3.3 types `Markdown.__call__` as `str | list[...]` (le + # renderer HTML renvoie toujours `str` à l'exécution). + html = cast(str, renderer(content)) return generate_pdf(build_pdf_html(html, title), title) except Exception as e: # WeasyPrint loads GTK lazily: a missing native library can surface at diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 3bc5f1b..8e8281f 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.28.14" +version = "2.28.15" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index ed3e2c4..fcd561f 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.28.14" +version = "2.28.15" 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 e8a8272..1d22037 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.14", + "version": "2.28.15", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docker-compose.yml b/docker-compose.yml index b8513e4..962e90a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,7 +53,12 @@ services: - OBSIGATE_AUTH_ENABLED=true - OBSIGATE_ADMIN_USER=admin # OBSIGATE_ADMIN_PASSWORD → .env - # OBSIGATE_SECURE_COOKIES=true # si derrière reverse proxy HTTPS + # OBSIGATE_SECURE_COOKIES : auto par défaut (Secure si https, sinon + # pas de flag) — forcer à true uniquement si le proxy termine TLS + # sans X-Forwarded-Proto (avec TRUST_PROXY, l'auto suffit). + # Reverse proxy devant l'app : IPs d'audit réelles (BUG-030) et + # X-Forwarded-Proto honoré pour les cookies Secure (auto). + - OBSIGATE_TRUST_PROXY=true - OLLAMA_BASE_URL=http://ollama:11434/v1 - OLLAMA_MODEL=qwen2.5-coder:1.5b env_file: diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 2bdc5f9..dfb5a89 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -195,7 +195,6 @@ Avant de corriger quoi que ce soit, un agent IA doit : | # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes | |---|---|---|---|---|---|---|---|---|---| -| *(exemple)* TODO-002 | Rendre l'index inversé incrémental (40k+ fichiers) | 🔴 ouvert | P1 | ⚙️ backend | IA | `backend/indexer.py`, `backend/search.py` | Recherche sur très gros vault | — | Exemple à remplacer. Cf. plan.md | | *(À remplir)* | | | | | | | | | | --- @@ -278,6 +277,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-27 | BUG-082 | Correction CI | `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-082** : `lint` rouge (`ERR_MODULE_NOT_FOUND: jsdom`, rouge depuis `7bee4a2`) — les fichiers de l'étape frontend racine à import statique `jsdom` (`upload.test.mjs`, puis `config-ai-keys.test.mjs` révélé par le CI après le 1er fix), alors que `jsdom` n'est installé que dans `tests/frontend/node_modules` (étape JSDOM). Les deux déplacés dans l'étape JSDOM (les deux branches) ; garde-fou `test_ci_workflow.py` généralisé (aucun fichier racine à import statique jsdom + suites verrouillées en JSDOM, contre-preuve OK). Vérifié : étape racine verte (11 suites) + `upload` et `config-ai-keys` verts depuis `tests/frontend/`. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-27 | BUG-083 | Correction CI | `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-083** : job `security` rouge — le runner Gitea Act tronque naïvement au premier `#` (même entre guillemets) : `echo "... see #87)"` devenait une citation non fermée (`unexpected EOF while looking for matching '"'"`, `/var/run/act/workflow/4` ligne 2). Seul `run:` du workflow avec un `#` (les `#` des noms d'étapes Bandit/Npm audit sont inoffensifs, ces étapes passent). Correctif : echo sans `#` (réf `#87` en commentaire YAML). Garde-fou `test_ci_workflow.py` (aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé en étape JSDOM — BUG-082) + contre-preuve sur l'ancien `ci.yml`. Vérifié : 56 passed. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-27 | BUG-081 | Correction | `backend/auth/router.py`, `tests/test_mfa.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-081** : `GET /api/auth/mfa/status` répondait 500 quand l'auth est désactivée — le pseudo-user `anonymous` n'a aucune entrée en store (`get_user` → `None`, `AttributeError` sur `user.get`). Garde `user is None` → payload « MFA désactivé ». Test `TestMfaStatusAuthDisabled` (échoue en 500 sans le correctif). Vérifié : `test_mfa.py` 32 passed, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) | +| 2026-09-27 | #87 T6, T7, T8 | Sécurité (fin #87) | `backend/requirements.txt`, `backend/{render,export}.py`, `backend/tools/documents.py`, `backend/auth/router.py`, `backend/main.py`, `semgrep-rules/` (nouveau), `.gitea/workflows/ci.yml`, `tests/test_i18n_parity.py` (nouveau), `tests/test_auth_api.py`, `tests/test_security_headers.py`, `docker-compose.yml`, `.env.example`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **T6** : dépendances qualifiées (mistune 3.3.3, multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 ; `cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant 0 vuln** (exception ecdsa/Minerva documentée : sans fix, HS256 only). **T7** : **semgrep bloquant** local 8 règles, 0 finding (trivy écarté : réseau). **T8** : Secure auto + `X-Forwarded-Proto` (`TRUST_PROXY`), warning affiné, CORS same-origin explicite, `style-src` résiduel assumé (189+343 sites) ; TODO exemple purgé, locales FR/EN 2213 parité testée, `npm audit` 0. | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d65da57..c4459ef 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.28.14 | **Dernière mise à jour :** 2026-09-27 +> **Version :** 2.28.15 | **Dernière mise à jour :** 2026-09-27 > **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)** @@ -68,11 +68,15 @@ - **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). +- **T6 livrée (v2.28.15) :** dépendances qualifiées — mistune 3.3.3, python-multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 (`cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant, 0 vulnérabilité** (seule exception documentée : PYSEC-2026-1325 ecdsa, sans correctif upstream, JWT HS256 uniquement). +- **T7 livrée (v2.28.15) :** **semgrep bloquant** sur ruleset 100 % local `semgrep-rules/` (8 règles, 0 finding, contrôle négatif OK) ; trivy écarté (binaire + DB réseau, couche Python couverte). +- **T8 livrée (v2.28.15, fin BUG-034) :** cookies `Secure` auto (`true|false|auto`, `X-Forwarded-Proto` sous `TRUST_PROXY`, warning affiné, `TRUST_PROXY=true` en prod) ; `CORSMiddleware` same-origin explicite ; `style-src 'unsafe-inline'` conservé assumé (189 `style=` + 343 `el.style`, T5c ayant verrouillé `script-src`). - **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) — **T4 livrée :** `tests/e2e/xss.spec.js` (BUG-021/022, 2/2 vert) + `scripts/e2e-server.ps1` (cycle de vie serveur E2E avec progression `start|stop|status|logs`) + validation locale projet `chromium-desktop` : **108/108 verts** (obsigate 44, split 37, viewers 24, xss/header 3), mobiles ciblés 10/10 - [ ] 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 + avertissement + CORS attesté) ; **T5a livrée (v2.28.6)** (16 handlers inline → listeners, CSP inchangée) ; **T5b livrée :** nonce frais par réponse (`backend/csp.py`, `script-src`), injection dans les 6 pages HTML (dont nouvelle route `/excalidraw-editor.html`), `unsafe-inline` conservé (inerte) + - [ ] 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 + avertissement + CORS attesté) ; **T5a livrée (v2.28.6)** (16 handlers inline → listeners, CSP inchangée) ; **T5b livrée :** nonce frais par réponse (`backend/csp.py`, `script-src`), injection dans les 6 pages HTML (dont nouvelle route `/excalidraw-editor.html`), `unsafe-inline` conservé (inerte) ; **T5c livrée (v2.28.13)** (`script-src` sans `unsafe-inline`) ; **T8 livrée (v2.28.15)** (fin BUG-034 : Secure auto + CORS explicite ; `style-src` résiduel assumé ; rotation DeepSeek BUG-006 toujours côté utilisateur) + - [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD — **T6/T9 livrées (v2.28.15)** (`pip-audit` 0, `npm audit` 0, locales FR/EN 2213 clés parité testée `test_i18n_parity.py`, gardes `test_version.py` + `test_ci_workflow.py`) - [ ] 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 39c77f3..1ef3f40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.28.14", + "version": "2.28.15", "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/semgrep-rules/obsigate-python.yaml b/semgrep-rules/obsigate-python.yaml new file mode 100644 index 0000000..52aaafc --- /dev/null +++ b/semgrep-rules/obsigate-python.yaml @@ -0,0 +1,70 @@ +# ObsiGate — règles Semgrep locales (#87 T7). +# +# Volontairement LOCALES (aucun `--config auto`/registre) : le runner CI a un +# accès réseau fragile, et ces règles n'ont besoin d'aucun téléchargement. +# Exécution : `semgrep --config semgrep-rules/ backend/` (job CI `lint`, +# bloquant). Chaque règle est un garde-fou : aucun code existant ne doit +# la déclencher (vérifié à l'ajout) ; toute violation future échoue le CI. +rules: + - id: obsigate-no-eval-exec + message: "Interdit : eval()/exec() sur du contenu dynamique (injection de code). Restructurer sans exécution de code." + severity: ERROR + languages: [python] + pattern-either: + - pattern: eval(...) + - pattern: exec(...) + + - id: obsigate-no-shell-true + message: "Interdit : subprocess avec shell=True (injection shell). Passer argv en liste, shell=False." + severity: ERROR + languages: [python] + pattern-either: + - pattern: subprocess.run(..., shell=True, ...) + - pattern: subprocess.Popen(..., shell=True, ...) + - pattern: subprocess.call(..., shell=True, ...) + - pattern: subprocess.check_output(..., shell=True, ...) + - pattern: subprocess.check_call(..., shell=True, ...) + + - id: obsigate-no-os-system + message: "Interdit : os.system() (shell implicite). Utiliser subprocess avec argv en liste." + severity: ERROR + languages: [python] + pattern: os.system(...) + + - id: obsigate-no-pickle-load + message: "Interdit : pickle.load/loads sur des données non fiables (exécution arbitraire). Utiliser JSON." + severity: ERROR + languages: [python] + pattern-either: + - pattern: pickle.load(...) + - pattern: pickle.loads(...) + + - id: obsigate-no-yaml-unsafe-load + message: "Interdit : yaml.load() sans Loader (exécution arbitraire). Utiliser yaml.safe_load()." + severity: ERROR + languages: [python] + patterns: + - pattern: yaml.load(...) + - pattern-not: yaml.load(..., Loader=...) + + - id: obsigate-no-unverified-tls + message: "Interdit : verify=False (MITM). Ne jamais désactiver la vérification TLS." + severity: ERROR + languages: [python] + pattern-either: + - pattern: requests.$METHOD(..., verify=False, ...) + - pattern: httpx.$METHOD(..., verify=False, ...) + - pattern: httpx.Client(..., verify=False, ...) + - pattern: httpx.AsyncClient(..., verify=False, ...) + + - id: obsigate-no-markupsafe-markup + message: "Interdit : markupsafe.Markup() (contourne l'échappement XSS, BUG-021/022). Le sanitizer serveur est la seule voie." + severity: ERROR + languages: [python] + pattern: Markup(...) + + - id: obsigate-no-tempfile-mktemp + message: "Interdit : tempfile.mktemp() (race symlink, CWE-377). Utiliser NamedTemporaryFile/mkdtemp." + severity: ERROR + languages: [python] + pattern: tempfile.mktemp(...) diff --git a/tests/test_auth_api.py b/tests/test_auth_api.py index bb8886c..d95abed 100644 --- a/tests/test_auth_api.py +++ b/tests/test_auth_api.py @@ -402,4 +402,83 @@ class TestAvatar: "username": "admin", "password": "chab30", }) assert resp.status_code == 200 - assert resp.json()["user"]["avatar"] == TINY_PNG \ No newline at end of file + assert resp.json()["user"]["avatar"] == TINY_PNG + + +# ═══════════════════════════════════════════════════════════════════ +# Secure cookies (#87 T8) +# ═══════════════════════════════════════════════════════════════════ + +class TestSecureCookies: + """`Secure` auto par défaut : https → flag, http → pas de flag + (les navigateurs jettent les cookies Secure sur http).""" + + @staticmethod + def _req(scheme="http", forwarded_proto=None): + from types import SimpleNamespace + headers = {} + if forwarded_proto is not None: + headers["x-forwarded-proto"] = forwarded_proto + return SimpleNamespace( + url=SimpleNamespace(scheme=scheme), + headers=headers, + ) + + def test_forced_true(self, monkeypatch): + from backend.auth.router import is_secure_cookies + monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", "true") + assert is_secure_cookies(self._req("http")) is True + + def test_forced_false(self, monkeypatch): + from backend.auth.router import is_secure_cookies + monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", "false") + assert is_secure_cookies(self._req("https")) is False + + def test_auto_http(self, monkeypatch): + from backend.auth.router import is_secure_cookies + monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False) + assert is_secure_cookies(self._req("http")) is False + + def test_auto_https(self, monkeypatch): + from backend.auth.router import is_secure_cookies + monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False) + assert is_secure_cookies(self._req("https")) is True + + def test_auto_forwarded_proto_trusted(self, monkeypatch): + from backend.auth.router import is_secure_cookies + monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False) + monkeypatch.setenv("OBSIGATE_TRUST_PROXY", "true") + assert is_secure_cookies(self._req("http", "https")) is True + + def test_auto_forwarded_proto_untrusted(self, monkeypatch): + from backend.auth.router import is_secure_cookies + monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False) + monkeypatch.delenv("OBSIGATE_TRUST_PROXY", raising=False) + assert is_secure_cookies(self._req("http", "https")) is False + + def test_login_http_sets_cookie_without_secure(self, auth_client, monkeypatch): + monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False) + resp = auth_client.post("/api/auth/login", json={ + "username": "admin", "password": "chab30", + }) + assert resp.status_code == 200 + set_cookie = resp.headers.get("set-cookie", "") + assert "access_token" in set_cookie + assert "secure" not in set_cookie.lower() + + def test_login_https_sets_secure_cookie(self, auth_client, monkeypatch): + """Même app servie en https → flag Secure présent.""" + from backend.main import app + from fastapi.testclient import TestClient + monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False) + https_client = TestClient(app, base_url="https://testserver", + raise_server_exceptions=False) + try: + resp = https_client.post("/api/auth/login", json={ + "username": "admin", "password": "chab30", + }) + assert resp.status_code == 200 + assert "secure" in resp.headers.get("set-cookie", "").lower() + finally: + if hasattr(https_client, "close"): + https_client.close() \ No newline at end of file diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index 494ec06..bb76d38 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -61,6 +61,17 @@ class TestRunnerProofScripts: ) +class TestSemgrepStep: + def test_semgrep_local_rules_enforced(self): + """#87 T7 : semgrep bloquant sur règles locales (aucun registre).""" + text = CI_YML.read_text(encoding="utf-8") + assert "semgrep --config semgrep-rules/ backend/" in text, ( + "#87 T7 : étape semgrep locale attendue dans le job security" + ) + rules = REPO_ROOT / "semgrep-rules" / "obsigate-python.yaml" + assert rules.exists(), "ruleset semgrep manquant" + + class TestFrontendStepsHaveTheirDeps: @staticmethod def _root_step_files() -> list[str]: diff --git a/tests/test_i18n_parity.py b/tests/test_i18n_parity.py new file mode 100644 index 0000000..609d393 --- /dev/null +++ b/tests/test_i18n_parity.py @@ -0,0 +1,34 @@ +"""Parité i18n FR/EN des locales du frontend (#87 T9). + +`frontend/locales/fr.json` et `en.json` doivent exposer exactement les mêmes +clés (comparaison profonde) : toute clé manquante fait afficher la clé brute +dans l'UI au lieu du libellé. +""" +from __future__ import annotations + +import json +from pathlib import Path + +LOCALES = Path(__file__).resolve().parent.parent / "frontend" / "locales" + + +def _flat(d: dict, prefix: str = "") -> set[str]: + keys = set() + for k, v in d.items(): + name = f"{prefix}.{k}" if prefix else str(k) + if isinstance(v, dict): + keys |= _flat(v, name) + else: + keys.add(name) + return keys + + +def _load(lang: str) -> set[str]: + return _flat(json.loads((LOCALES / f"{lang}.json").read_text(encoding="utf-8"))) + + +class TestI18nParity: + def test_fr_en_same_keys(self): + fr, en = _load("fr"), _load("en") + assert not (fr - en), f"clés sans traduction EN : {sorted(fr - en)[:10]}" + assert not (en - fr), f"clés sans traduction FR : {sorted(en - fr)[:10]}" diff --git a/tests/test_security_headers.py b/tests/test_security_headers.py index a9ad9a9..94d468d 100644 --- a/tests/test_security_headers.py +++ b/tests/test_security_headers.py @@ -1,31 +1,34 @@ -"""Tests — cookies Secure, CORS same-origin implicite, avertissement bind (ROADMAP #87 T3). +"""Tests — cookies Secure, CORS same-origin explicite, avertissement bind (ROADMAP #87 T3/T8). -- `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). +- `is_secure_cookies()` : `OBSIGATE_SECURE_COOKIES=true|false|auto` (défaut + `auto` : Secure si la requête arrive en https, sinon pas de flag — les + navigateurs ignorent les cookies `Secure` en clair). +- CORS same-origin EXPLICITE : `CORSMiddleware(allow_origins=[])` — aucun + `Access-Control-Allow-*` n'est émis même avec un `Origin` cross-origin, + et les preflights sont rejetés (400). """ from __future__ import annotations -def test_secure_cookies_default_false(monkeypatch): - """Défaut `false` (logins HTTP locaux préservés).""" +def test_secure_cookies_default_auto(monkeypatch): + """Défaut `auto` : sans requête → pas de flag (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).""" +def test_secure_cookies_forced_values(monkeypatch): + """`true`/`1`/`yes` → flag ; `false`/`0`/`no` → pas de flag (insensible à la casse).""" from backend.auth.router import is_secure_cookies - for value in ("true", "True", "TRUE", "1", "yes"): + for value in ("true", "True", "TRUE", "1", "yes", "on"): monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", value) - assert is_secure_cookies() is (value.lower() == "true") + assert is_secure_cookies() is True + for value in ("false", "False", "FALSE", "0", "no", "off"): + monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", value) + assert is_secure_cookies() is False def test_no_cors_headers_on_api(client): @@ -42,6 +45,25 @@ def test_no_cors_headers_on_public_share(client): assert "access-control-allow-origin" not in {k.lower() for k in resp.headers} +def test_cross_origin_get_emits_no_acao(client): + """#87 T8 : même avec un `Origin` cross-origin, aucun ACAO (refus explicite).""" + resp = client.get("/api/health", headers={"Origin": "http://evil.example"}) + assert resp.status_code == 200 + assert "access-control-allow-origin" not in {k.lower() for k in resp.headers} + + +def test_cross_origin_preflight_rejected(client): + """#87 T8 : preflight cross-origin → 400 (origine non autorisée).""" + resp = client.options( + "/api/health", + headers={ + "Origin": "http://evil.example", + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 400 + + def test_security_headers_present(client): """En-têtes de durcissement posés par le middleware (non-régression).""" resp = client.get("/api/health")