securite: #87 T6-T8 fin dette — deps qualifiées, semgrep, Secure auto, CORS

This commit is contained in:
2026-09-27 12:43:31 -04:00
parent b6e2029770
commit a3973b981c
24 changed files with 390 additions and 73 deletions
+6 -5
View File
@@ -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
+16 -9
View File
@@ -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:
+34 -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.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
---
+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.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*
+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.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*
+1 -1
View File
@@ -1 +1 @@
2.28.14
2.28.15
+35 -16
View File
@@ -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 ───────────────────────────────────────────────────
+4 -1
View File
@@ -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:
+27 -3
View File
@@ -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:<port>, 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
+4 -1
View File
@@ -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)
+6 -6
View File
@@ -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
+4 -2
View File
@@ -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
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.28.14"
version = "2.28.15"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -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"
+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.14",
"version": "2.28.15",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+6 -1
View File
@@ -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:
+1 -1
View File
@@ -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) |
---
+6 -2
View File
@@ -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
---
+1 -1
View File
@@ -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": {
+70
View File
@@ -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(...)
+79
View File
@@ -403,3 +403,82 @@ class TestAvatar:
})
assert resp.status_code == 200
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()
+11
View File
@@ -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]:
+34
View File
@@ -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]}"
+35 -13
View File
@@ -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")