securite: #87 T6-T8 fin dette — deps qualifiées, semgrep, Secure auto, CORS
This commit is contained in:
+6
-5
@@ -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.
|
# (ex. 0.0.0.0) sauf si l'on force l'opt-in ci-dessous. À réserver au local.
|
||||||
# OBSIGATE_ALLOW_INSECURE=false
|
# OBSIGATE_ALLOW_INSECURE=false
|
||||||
|
|
||||||
# Sécurité des cookies (activer si derrière HTTPS)
|
# Sécurité des cookies : true|false|auto (défaut : auto — Secure si la
|
||||||
# false par défaut : les navigateurs ignorent les cookies `Secure` en HTTP,
|
# requête arrive en https, sinon pas de flag ; les navigateurs ignorent les
|
||||||
# ce qui casserait les logins en local. En production (TLS + bind réseau),
|
# cookies `Secure` en HTTP, ce qui casserait les logins en local).
|
||||||
# posez true — un avertissement est loggé au démarrage sinon (#87).
|
# Derrière un reverse proxy qui termine TLS, auto suffit avec
|
||||||
# OBSIGATE_SECURE_COOKIES=false
|
# OBSIGATE_TRUST_PROXY=true (X-Forwarded-Proto honoré).
|
||||||
|
# OBSIGATE_SECURE_COOKIES=auto
|
||||||
|
|
||||||
# Tokens TTL en secondes
|
# Tokens TTL en secondes
|
||||||
# OBSIGATE_ACCESS_TOKEN_TTL=31536000000 # 1000 ans
|
# OBSIGATE_ACCESS_TOKEN_TTL=31536000000 # 1000 ans
|
||||||
|
|||||||
+16
-9
@@ -130,7 +130,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
pip install bandit pip-audit
|
pip install bandit pip-audit semgrep
|
||||||
pip install -r backend/requirements.txt
|
pip install -r backend/requirements.txt
|
||||||
|
|
||||||
- name: Bandit (SAST, bloquant — #87)
|
- name: Bandit (SAST, bloquant — #87)
|
||||||
@@ -139,14 +139,21 @@ jobs:
|
|||||||
# vrais positifs restants portent un `# nosec` justifié inline.
|
# vrais positifs restants portent un `# nosec` justifié inline.
|
||||||
run: bandit -r backend/ --skip B101,B105,B110,B310
|
run: bandit -r backend/ --skip B101,B105,B110,B310
|
||||||
|
|
||||||
- name: Pip-audit (consultatif — #87)
|
- name: Semgrep (SAST local, bloquant — #87)
|
||||||
# Reste non bloquant tant que les montées de version requises
|
# Règles 100 % locales (semgrep-rules/, 8 règles) : aucun
|
||||||
# (starlette via fastapi, weasyprint) ne sont pas qualifiées :
|
# téléchargement de registre (runner au réseau fragile).
|
||||||
# upgrade FastAPI = chantier de régression dédié, hors périmètre.
|
run: semgrep --config semgrep-rules/ backend/
|
||||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`
|
|
||||||
# ci-dessous (tronqué au premier `#`, même entre guillemets, ce qui
|
- name: Pip-audit (bloquant — #87)
|
||||||
# cassait la citation de l'echo) — la réf #87 ne vit qu'ici.
|
# Bloquant depuis T6 (#87) : dépendances qualifiées (mistune 3.3.3,
|
||||||
run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking)"
|
# 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 ──────────────────────────────────────────────────
|
# ── Docker build ──────────────────────────────────────────────────
|
||||||
build:
|
build:
|
||||||
|
|||||||
+34
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
|||||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
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
|
> **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
|
## [2.28.14] — 2026-09-27
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
@@ -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.
|
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
+35
-16
@@ -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_account_success as rl_record_account_success
|
||||||
from backend.ratelimit import record_failure as rl_record_failure
|
from backend.ratelimit import record_failure as rl_record_failure
|
||||||
from backend.ratelimit import record_success as rl_record_success
|
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 (
|
from .jwt_handler import (
|
||||||
ACCESS_TOKEN_EXPIRE_SECONDS,
|
ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||||
@@ -57,15 +57,32 @@ logger = logging.getLogger("obsigate.auth.router")
|
|||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
def is_secure_cookies() -> bool:
|
def is_secure_cookies(request: Request | None = None) -> bool:
|
||||||
"""True when auth cookies must carry the ``Secure`` flag (#87 T3).
|
"""True when auth cookies must carry the ``Secure`` flag (#87 T3/T8).
|
||||||
|
|
||||||
Opt-in via ``OBSIGATE_SECURE_COOKIES=true`` (required behind TLS).
|
``OBSIGATE_SECURE_COOKIES=true|false|auto`` (défaut : ``auto``) :
|
||||||
Default stays ``false`` so logins keep working over plain HTTP on
|
``true``/``false`` forcent le comportement ; ``auto`` met ``Secure``
|
||||||
trusted loopback deployments — browsers drop ``Secure`` cookies sent
|
si la requête arrive en https (production derrière TLS) et l'omet
|
||||||
over HTTP, which would silently break localhost logins.
|
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 ──────────────────────────────────────────
|
# ── Pydantic request models ──────────────────────────────────────────
|
||||||
@@ -230,10 +247,11 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
|||||||
"remember_me": body.remember_me,
|
"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)."""
|
"""Issue JWT tokens after successful authentication (password or MFA verified)."""
|
||||||
record_login_success(username)
|
record_login_success(username)
|
||||||
rl_record_account_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)
|
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
|
||||||
|
|
||||||
max_age = 2592000 if remember_me else 604800 # 30d or 7d
|
max_age = 2592000 if remember_me else 604800 # 30d or 7d
|
||||||
secure = is_secure_cookies()
|
secure = is_secure_cookies(request)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="refresh_token",
|
key="refresh_token",
|
||||||
value=refresh_token,
|
value=refresh_token,
|
||||||
@@ -311,7 +329,7 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
|||||||
if stale:
|
if stale:
|
||||||
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
|
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))
|
remember_me = bool(payload.get("remember", False))
|
||||||
|
|
||||||
# BUG-027: rotate the refresh token — the old one is now single-use.
|
# 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(
|
async def change_password(
|
||||||
req: ChangePasswordRequest,
|
req: ChangePasswordRequest,
|
||||||
response: Response,
|
response: Response,
|
||||||
|
request: Request,
|
||||||
current_user=Depends(require_auth),
|
current_user=Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""Change own password.
|
"""Change own password.
|
||||||
@@ -452,7 +471,7 @@ async def change_password(
|
|||||||
updated = get_user(current_user["username"])
|
updated = get_user(current_user["username"])
|
||||||
result: dict = {"message": "Mot de passe mis à jour"}
|
result: dict = {"message": "Mot de passe mis à jour"}
|
||||||
if updated is not None:
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -815,7 +834,7 @@ async def mfa_webauthn_verify(
|
|||||||
|
|
||||||
rl_record_success(client_ip)
|
rl_record_success(client_ip)
|
||||||
logger.info(f"User '{body.username}' logged in via WebAuthn")
|
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")
|
@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
|
# Clear IP rate limit on success
|
||||||
rl_record_success(client_ip)
|
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")
|
@router.post("/mfa/recovery")
|
||||||
@@ -906,7 +925,7 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
|
|||||||
rl_record_success(client_ip)
|
rl_record_success(client_ip)
|
||||||
|
|
||||||
logger.info(f"User '{body.username}' logged in via recovery code")
|
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 ───────────────────────────────────────────────────
|
# ── Admin endpoints ───────────────────────────────────────────────────
|
||||||
|
|||||||
+4
-1
@@ -23,6 +23,7 @@ import re
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
import frontmatter
|
import frontmatter
|
||||||
import mistune
|
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)."""
|
"""Render raw markdown to an HTML fragment (images inlined, wikilinks resolved)."""
|
||||||
md = _inline_images(md, file_dir, vault_path)
|
md = _inline_images(md, file_dir, vault_path)
|
||||||
md = _convert_wikilinks(md, vault_path, current)
|
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:
|
def _build_nav(vault_path: Path, current: Path) -> str:
|
||||||
|
|||||||
+27
-3
@@ -260,12 +260,19 @@ async def lifespan(app: FastAPI):
|
|||||||
# BUG-037: refuse to expose an unauthenticated instance on a public bind.
|
# BUG-037: refuse to expose an unauthenticated instance on a public bind.
|
||||||
_guard_insecure_auth()
|
_guard_insecure_auth()
|
||||||
|
|
||||||
# #87 T3 : avertir quand les cookies d'auth circulent sans flag Secure
|
# #87 T3/T8 : avertir quand les cookies d'auth circulent sans flag Secure
|
||||||
# sur un bind non-loopback (transactions observables en clair).
|
# 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.middleware import bind_host_from_argv, is_auth_enabled, is_loopback_host
|
||||||
from backend.auth.router import is_secure_cookies
|
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(
|
logger.warning(
|
||||||
"Cookies d'authentification sans flag `Secure` sur un bind non-loopback : "
|
"Cookies d'authentification sans flag `Secure` sur un bind non-loopback : "
|
||||||
"activez TLS et posez OBSIGATE_SECURE_COOKIES=true en production."
|
"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
|
# GZip compression — reduces bandwidth by ~70% for text responses
|
||||||
# Custom wrapper: skip compression for SSE streams (/api/events)
|
# Custom wrapper: skip compression for SSE streams (/api/events)
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
from starlette.types import Receive, Scope, Send
|
from starlette.types import Receive, Scope, Send
|
||||||
|
|
||||||
|
|
||||||
@@ -418,6 +426,22 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000)
|
|||||||
# Security headers on all responses
|
# Security headers on all responses
|
||||||
app.add_middleware(SecurityHeadersMiddleware)
|
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
|
# Auth router
|
||||||
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
|
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
|
||||||
from backend.ai_routes import router as ai_router
|
from backend.ai_routes import router as ai_router
|
||||||
|
|||||||
+4
-1
@@ -15,6 +15,7 @@ import html as html_mod
|
|||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
import mistune
|
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)
|
# Normalize line breaks to match Obsidian behavior (single \n → hard break)
|
||||||
converted = _normalize_line_breaks(converted)
|
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
|
# Add heading IDs for TOC navigation
|
||||||
rendered = _add_heading_ids(rendered)
|
rendered = _add_heading_ids(rendered)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
fastapi==0.110.3
|
fastapi==0.141.1
|
||||||
uvicorn==0.30.0
|
uvicorn==0.54.0
|
||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
python-frontmatter==1.1.0
|
python-frontmatter==1.1.0
|
||||||
mistune==3.0.2
|
mistune==3.3.3
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.31
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
aiohttp>=3.9.0
|
aiohttp>=3.9.0
|
||||||
watchdog>=4.0.0
|
watchdog>=4.0.0
|
||||||
@@ -11,7 +11,7 @@ argon2-cffi>=23.1.0
|
|||||||
python-jose>=3.3.0
|
python-jose>=3.3.0
|
||||||
sortedcontainers>=2.4.0
|
sortedcontainers>=2.4.0
|
||||||
snowballstemmer>=2.2.0
|
snowballstemmer>=2.2.0
|
||||||
weasyprint>=60.0
|
weasyprint>=70.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
pypdf>=4.0
|
pypdf>=4.0
|
||||||
pyotp>=2.10.0
|
pyotp>=2.10.0
|
||||||
@@ -19,7 +19,7 @@ segno>=1.5.0
|
|||||||
webauthn==2.6.0
|
webauthn==2.6.0
|
||||||
psutil>=5.9
|
psutil>=5.9
|
||||||
pywebpush>=2.3.0
|
pywebpush>=2.3.0
|
||||||
mcp==1.9.4
|
mcp==1.28.1
|
||||||
sse-starlette==2.1.3
|
sse-starlette==2.1.3
|
||||||
openpyxl>=3.1
|
openpyxl>=3.1
|
||||||
python-docx>=1.1
|
python-docx>=1.1
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import csv as csv_lib
|
|||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
# saxutils.escape uniquement (échappement de chaînes, aucun parsing XML).
|
# saxutils.escape uniquement (échappement de chaînes, aucun parsing XML).
|
||||||
from xml.sax import saxutils # nosec B406
|
from xml.sax import saxutils # nosec B406
|
||||||
@@ -173,7 +173,9 @@ def _render_markdown_pdf(content: str, title: str) -> bytes | None:
|
|||||||
escape=False,
|
escape=False,
|
||||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
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)
|
return generate_pdf(build_pdf_html(html, title), title)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# WeasyPrint loads GTK lazily: a missing native library can surface at
|
# WeasyPrint loads GTK lazily: a missing native library can surface at
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.28.14"
|
version = "2.28.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.28.14"
|
version = "2.28.15"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.28.14",
|
"version": "2.28.15",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
+6
-1
@@ -53,7 +53,12 @@ services:
|
|||||||
- OBSIGATE_AUTH_ENABLED=true
|
- OBSIGATE_AUTH_ENABLED=true
|
||||||
- OBSIGATE_ADMIN_USER=admin
|
- OBSIGATE_ADMIN_USER=admin
|
||||||
# OBSIGATE_ADMIN_PASSWORD → .env
|
# 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_BASE_URL=http://ollama:11434/v1
|
||||||
- OLLAMA_MODEL=qwen2.5-coder:1.5b
|
- OLLAMA_MODEL=qwen2.5-coder:1.5b
|
||||||
env_file:
|
env_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 |
|
| # | 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)* | | | | | | | | | |
|
| *(À 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-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-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 | 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
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# 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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **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/`
|
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** `.gitea/workflows/`, `tests/`
|
||||||
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
|
- **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).
|
- **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.
|
- **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 :**
|
- **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
|
- [ ] 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)
|
- [ ] 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
|
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -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(...)
|
||||||
@@ -403,3 +403,82 @@ class TestAvatar:
|
|||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["user"]["avatar"] == TINY_PNG
|
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()
|
||||||
@@ -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:
|
class TestFrontendStepsHaveTheirDeps:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _root_step_files() -> list[str]:
|
def _root_step_files() -> list[str]:
|
||||||
|
|||||||
@@ -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]}"
|
||||||
@@ -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` :
|
- `is_secure_cookies()` : `OBSIGATE_SECURE_COOKIES=true|false|auto` (défaut
|
||||||
compatibilité logins en HTTP local — les navigateurs ignorent les cookies
|
`auto` : Secure si la requête arrive en https, sinon pas de flag — les
|
||||||
`Secure` en clair).
|
navigateurs ignorent les cookies `Secure` en clair).
|
||||||
- Aucun en-tête CORS permissif n'est émis : sans `CORSMiddleware`, les
|
- CORS same-origin EXPLICITE : `CORSMiddleware(allow_origins=[])` — aucun
|
||||||
navigateurs appliquent le same-origin par défaut (politique explicite par
|
`Access-Control-Allow-*` n'est émis même avec un `Origin` cross-origin,
|
||||||
l'absence — vérifiée ici pour qu'un ajout futur de CORS soit conscient).
|
et les preflights sont rejetés (400).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
def test_secure_cookies_default_false(monkeypatch):
|
def test_secure_cookies_default_auto(monkeypatch):
|
||||||
"""Défaut `false` (logins HTTP locaux préservés)."""
|
"""Défaut `auto` : sans requête → pas de flag (logins HTTP locaux préservés)."""
|
||||||
from backend.auth.router import is_secure_cookies
|
from backend.auth.router import is_secure_cookies
|
||||||
|
|
||||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||||
assert is_secure_cookies() is False
|
assert is_secure_cookies() is False
|
||||||
|
|
||||||
|
|
||||||
def test_secure_cookies_opt_in(monkeypatch):
|
def test_secure_cookies_forced_values(monkeypatch):
|
||||||
"""`OBSIGATE_SECURE_COOKIES=true` → flag actif (insensible à la casse)."""
|
"""`true`/`1`/`yes` → flag ; `false`/`0`/`no` → pas de flag (insensible à la casse)."""
|
||||||
from backend.auth.router import is_secure_cookies
|
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)
|
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):
|
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}
|
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):
|
def test_security_headers_present(client):
|
||||||
"""En-têtes de durcissement posés par le middleware (non-régression)."""
|
"""En-têtes de durcissement posés par le middleware (non-régression)."""
|
||||||
resp = client.get("/api/health")
|
resp = client.get("/api/health")
|
||||||
|
|||||||
Reference in New Issue
Block a user