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.
|
||||
# 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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
+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_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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.28.14"
|
||||
version = "2.28.15"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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,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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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.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:
|
||||
@staticmethod
|
||||
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` :
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user