From 34fce932cbd0e8e9eea5d70079633dd538333985 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 26 Sep 2026 18:37:16 -0400 Subject: [PATCH] securite: #87 T3 cookies Secure centralises + CORS atteste (defaut inchange) --- .env.example | 3 ++ CHANGELOG.md | 13 ++++++++- README.fr.md | 6 ++-- README.md | 6 ++-- VERSION | 2 +- backend/auth/router.py | 19 +++++++++---- backend/main.py | 11 +++++++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 4 +-- package.json | 2 +- tests/test_security_headers.py | 52 ++++++++++++++++++++++++++++++++++ 13 files changed, 105 insertions(+), 19 deletions(-) create mode 100644 tests/test_security_headers.py diff --git a/.env.example b/.env.example index 256ea30..136d478 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,9 @@ OBSIGATE_ADMIN_PASSWORD=chab30 # 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 # Tokens TTL en secondes diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ee640d..58c8605 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.28.2**. +> [Unreleased](#unreleased). La dernière version livrée est **2.28.3**. --- @@ -14,10 +14,21 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.28.3] — 2026-09-26 + +--- + ## [2.28.2] — 2026-09-26 ### Ajouté +- **#87 (T3) — cookies `Secure` et CORS explicites.** + Helper `is_secure_cookies()` centralisé (défaut `false` conservé pour ne + pas casser les logins HTTP locaux) + avertissement au démarrage sur bind + non-loopback sans `Secure` ; `tests/test_security_headers.py` atteste + l'absence de CORS permissif (same-origin par défaut du navigateur) et les + en-têtes de durcissement. + - **#87 (T2) — tests de durcissement : concurrence et regex.** `tests/test_hardening_concurrency.py` : créations/mises à jour/`login failures` concurrents sur `users.json` (zéro mise à jour perdue, JSON diff --git a/README.fr.md b/README.fr.md index b2cf2a7..7cd40d9 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.28.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.28.3-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.2). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.3). --- -*Projet : ObsiGate | Version : 2.28.2 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.28.3 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 4c33f29..1c7bc7c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.28.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.28.3-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.2). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.3). --- -*Project: ObsiGate | Version: 2.28.2 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.28.3 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 0bd6cbc..1eb56ea 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.28.2 +2.28.3 diff --git a/backend/auth/router.py b/backend/auth/router.py index dcebd49..b379478 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -5,6 +5,7 @@ import base64 import binascii import logging +import os import re from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response @@ -56,6 +57,17 @@ 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). + + 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. + """ + return os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true" + + # ── Pydantic request models ────────────────────────────────────────── class LoginRequest(BaseModel): @@ -229,9 +241,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon access_token = create_access_token(user) refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me) - import os max_age = 2592000 if remember_me else 604800 # 30d or 7d - secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true" + secure = is_secure_cookies() response.set_cookie( key="refresh_token", value=refresh_token, @@ -300,9 +311,7 @@ async def refresh_token_endpoint(request: Request, response: Response): if stale: raise HTTPException(401, "Session expirée, veuillez vous reconnecter") - import os - - secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true" + secure = is_secure_cookies() remember_me = bool(payload.get("remember", False)) # BUG-027: rotate the refresh token — the old one is now single-use. diff --git a/backend/main.py b/backend/main.py index 18d313f..0716d98 100644 --- a/backend/main.py +++ b/backend/main.py @@ -249,6 +249,17 @@ 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). + from backend.auth.middleware import bind_host_from_argv, is_auth_enabled, is_loopback_host + from backend.auth.router import is_secure_cookies + + if is_auth_enabled() and not is_secure_cookies() and not is_loopback_host(bind_host_from_argv()): + logger.warning( + "Cookies d'authentification sans flag `Secure` sur un bind non-loopback : " + "activez TLS et posez OBSIGATE_SECURE_COOKIES=true en production." + ) + # Bootstrap admin account if needed bootstrap_admin() diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 94bcbfc..43e80e0 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.28.2" +version = "2.28.3" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index eb68f5e..9b2de3e 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.28.2" +version = "2.28.3" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 2606d93..57873c9 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.28.2", + "version": "2.28.3", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index da50738..728a79d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.28.2 | **Dernière mise à jour :** 2026-09-26 +> **Version :** 2.28.3 | **Dernière mise à jour :** 2026-09-26 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -72,7 +72,7 @@ - **Sous-tâches :** - [ ] Jobs CI sécurité **bloquants** (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown) - [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques ; intégrer au CI les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`) — **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) + - [ ] 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 `is_secure_cookies()` centralisé (défaut `false` conservé : compatibilité HTTP local), avertissement au démarrage sur bind non-loopback sans `Secure`, absence CORS attestée par test (`tests/test_security_headers.py`) ; bascule du défaut reportée (casserait les logins localhost) - [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD --- diff --git a/package.json b/package.json index a465a24..2f8f549 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.28.2", + "version": "2.28.3", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_security_headers.py b/tests/test_security_headers.py new file mode 100644 index 0000000..a9ad9a9 --- /dev/null +++ b/tests/test_security_headers.py @@ -0,0 +1,52 @@ +"""Tests — cookies Secure, CORS same-origin implicite, avertissement bind (ROADMAP #87 T3). + +- `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). +""" + +from __future__ import annotations + + +def test_secure_cookies_default_false(monkeypatch): + """Défaut `false` (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).""" + from backend.auth.router import is_secure_cookies + + for value in ("true", "True", "TRUE", "1", "yes"): + monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", value) + assert is_secure_cookies() is (value.lower() == "true") + + +def test_no_cors_headers_on_api(client): + """Pas de `Access-Control-Allow-Origin` : same-origin imposé par le navigateur.""" + resp = client.get("/api/health") + assert resp.status_code == 200 + assert "access-control-allow-origin" not in {k.lower() for k in resp.headers} + + +def test_no_cors_headers_on_public_share(client): + """Idem sur la page publique de partage.""" + resp = client.get("/s/jeton-inexistant") + assert resp.status_code == 404 + assert "access-control-allow-origin" not in {k.lower() for k in resp.headers} + + +def test_security_headers_present(client): + """En-têtes de durcissement posés par le middleware (non-régression).""" + resp = client.get("/api/health") + assert resp.headers.get("x-content-type-options") == "nosniff" + assert resp.headers.get("x-frame-options") == "SAMEORIGIN" + csp = resp.headers.get("content-security-policy", "") + assert "object-src 'none'" in csp + assert "frame-ancestors 'self'" in csp