securite: #87 T5b nonces CSP prets pour bascule (sans changement)

This commit is contained in:
2026-09-26 22:44:12 -04:00
parent 36a4030c09
commit d70ecd0968
15 changed files with 278 additions and 85 deletions
+89 -53
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.28.6**.
> [Unreleased](#unreleased). La dernière version livrée est **2.28.7**.
---
@@ -14,8 +14,29 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.28.7] — 2026-09-26
### Ajouté
- **#87 (T5b) — nonces CSP prêts pour la bascule (sans changement).**
Nonce frais par réponse dans `script-src` (`backend/csp.py`), injecté
dans les 6 pages HTML servies (dont la nouvelle route
`/excalidraw-editor.html`, utilisée par l'iframe du viewer au lieu de
`/static/`) et la page de partage ; `tests/test_csp_nonce.py` (unicité,
concordance en-tête/HTML). `unsafe-inline` conservé jusqu'en T5c.
---
## [2.28.6] — 2026-09-26
### Modifié
- **#87 (T5a) — 16 handlers inline convertis en listeners (CSP inchangée).**
`onclick`/`onerror` de `index.html` et des vues JS (`config`, `plugins`,
`sync`, `viewer`, `auth`) remplacés par `addEventListener` ; suites
frontend vertes (unit, ai, config-mobile, pdf-viewer, mfa-settings,
sidebar-filters).
---
## [2.28.5] — 2026-09-26
@@ -24,14 +45,6 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.28.4] — 2026-09-26
---
## [2.28.3] — 2026-09-26
---
## [2.28.2] — 2026-09-26
### Ajouté
- **#87 (T4) — E2E XSS et serveur E2E piloté.**
@@ -41,6 +54,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
`scripts/e2e-server.ps1` : `start|stop|status|logs` avec progression
visible et fichier PID (fini les serveurs orphelins sur le port 2029).
---
## [2.28.3] — 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
@@ -48,6 +67,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
l'absence de CORS permissif (same-origin par défaut du navigateur) et les
en-têtes de durcissement.
---
## [2.28.2] — 2026-09-26
### Ajouté
- **#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
@@ -73,50 +98,6 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.28.0] — 2026-09-26
---
## [2.27.12] — 2026-09-26
---
## [2.27.11] — 2026-09-26
---
## [2.27.10] — 2026-09-26
---
## [2.27.9] — 2026-09-26
---
## [2.27.8] — 2026-09-26
---
## [2.27.7] — 2026-09-26
---
## [2.27.6] — 2026-09-26
---
## [2.27.5] — 2026-09-26
---
## [2.27.4] — 2026-09-26
---
## [2.27.3] — 2026-09-26
---
## [2.27.2] — 2026-09-26
### Modifié
- **#85 (T10) — persistance d'état et clôture de la refonte architecturale.**
@@ -129,6 +110,11 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
câblés, rien à coder). Index non persisté : rebuild différentiel #86
suffisant (décision documentée). Fiche `docs/features/archi-refonte-85.md`,
#85 sorti du backlog (index roadmap).
## [2.27.12] — 2026-09-26
### Modifié
- **#85 (T9) — extraction realtime + render hors du monolithe `backend/main.py`.**
Le stream SSE `/api/events` et le WebSocket `/ws/collab/*` sont servis par
`backend/routers/realtime.py`, le pipeline markdown (mistune, wikilinks,
@@ -136,30 +122,55 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
couplage différé). `main.py` (4 827 → ~760 lignes) ne contient plus que
l'assemblage : lifespan, middlewares, montage des 16 routers, racine
`/api`, statique/SPA et cales de compatibilité testées.
## [2.27.11] — 2026-09-26
### Modifié
- **#85 (T8) — extraction vaults/history/conflicts hors du monolithe `backend/main.py`.**
13 routes servies par `backend/routers/vaults.py`, `history.py` et
`conflicts.py` ; `VaultInfo`/`BookmarkToggleRequest` dans `schemas.py`,
handle watcher partagé dans `backend/watcher_state.py`.
`tests/test_api_main.py` importe `humanize_mtime` depuis son module
canonique (`services.recent`).
## [2.27.10] — 2026-09-26
### Modifié
- **#85 (T7) — extraction du domaine `config` hors du monolithe `backend/main.py`.**
`/api/config`, ai-keys (get/post/delete/test), tool-keys (×3), ai-models,
diagnostics et dashboard sont servis par `backend/routers/config.py`
(`_FALLBACK_MODELS`, store clés et config déplacés ; `main` réimporte
`_load_config` pour son lifespan, les fixtures de tests inchangées).
`tests/test_ai_models.py` patch désormais la référence du router.
## [2.27.9] — 2026-09-26
### Modifié
- **#85 (T6c) — extraction media/pdf/export/guide hors du monolithe `backend/main.py`.**
file/pdf, exports (html/md-bundle/epub), guide/download, pdf/stream|info,
image, media+thumb, attachments (rescan/stats), vault settings (get/post/all)
et vault files sont servis par `backend/routers/files_media.py` ; le helper
Range partagé vit dans `backend/routers/helpers.py` (tags OpenAPI inchangés,
tests statiques frontend `media-viewer`/`image-viewer` réalignés).
## [2.27.8] — 2026-09-26
### Modifié
- **#85 (T6b) — extraction mutations fichiers/dossiers hors du monolithe `backend/main.py`.**
`PUT .../save|xlsx/save`, `DELETE/POST/PATCH /api/file`, `POST/PATCH/DELETE
/api/directory`, `POST /api/move`, `POST .../batch-upload` sont servis par
le nouveau `backend/routers/files_write.py` (effets de bord inchangés :
audit, index, SSE, webhooks, plugins, historique) ; 15 modèles dans
`schemas.py`.
## [2.27.7] — 2026-09-26
### Modifié
- **#85 (T6a) — extraction lecture fichiers hors du monolithe `backend/main.py`.**
`/api/browse/{vault}`, `/api/file/{vault}/raw|download|backlinks` et
`GET /api/file/{vault}` (vue rendue tous formats) sont servis par le
@@ -168,6 +179,11 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
`backend/routers/helpers.py` (partagés avec les tranches suivantes).
Correctif au passage : décorateur orphelin `/s/{token}` resté en T3 et
double-enregistrement de `/api/conflicts` supprimés.
## [2.27.6] — 2026-09-26
### Modifié
- **#85 (T5) — extraction du domaine `search` hors du monolithe `backend/main.py`.**
Les 11 routes (`/api/search`, `/advanced`, `/replace`, `/tags`,
`/tree-search`, `/vault/{vault}/paths`, `/suggest`, `/tags/suggest`,
@@ -175,22 +191,42 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
par le nouveau `backend/routers/search.py` ; les modèles search dans
`schemas.py` et le pool de threads dans `backend/search_executor.py`
(même dimensionnement, même cycle de vie) — comportement inchangé.
## [2.27.5] — 2026-09-26
### Modifié
- **#85 (T4) — extraction du domaine `backups` hors du monolithe `backend/main.py`.**
Les 9 routes (`/api/file/{vault}/backups|diff|restore`, `/api/backups`,
`/delete`, `/purge`, `/content`, `/compress`, `/auto`) sont servies par le
nouveau `backend/routers/backups.py` ; `Diff/Restore*` déménagent dans
`schemas.py` et le singleton SSE dans `backend/sse.py` (partagé avec
`main`) — comportement inchangé, aucun impact utilisateur.
## [2.27.4] — 2026-09-26
### Modifié
- **#85 (T3) — extraction du domaine `sharing` hors du monolithe `backend/main.py`.**
`POST /api/share/{vault}`, `GET /api/shares`, `DELETE /api/share/{share_id}`
et les pages publiques `/s/{token}`, `/s/{token}/raw`, `/s/{token}/pdf`
sont servis par le nouveau `backend/routers/sharing.py` — chemins,
réponses, tags OpenAPI et authentification inchangés (aucun impact
utilisateur).
## [2.27.3] — 2026-09-26
### Modifié
- **#85 (T2) — extraction du domaine `webhooks` hors du monolithe `backend/main.py`.**
Le CRUD `GET/POST/PATCH/DELETE /api/webhooks` (admin) est servi par le
nouveau `backend/routers/webhooks.py` — chemins, réponses, tags OpenAPI et
authentification inchangés (aucun impact utilisateur).
## [2.27.2] — 2026-09-26
### Modifié
- **#85 (T1) — extraction du domaine `health` hors du monolithe `backend/main.py`.**
`GET /api/health` et `GET /api/health/detailed` (admin) sont servis par le
nouveau `backend/routers/health.py` (monté dans `main.py`) et le modèle
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.28.6-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.28.7-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.6).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.7).
---
*Projet : ObsiGate | Version : 2.28.6 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.28.7 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.28.6-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.28.7-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.6).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.7).
---
*Project: ObsiGate | Version: 2.28.6 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.28.7 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.28.6
2.28.7
+35
View File
@@ -0,0 +1,35 @@
"""Content-Security-Policy nonces (ROADMAP #87, tranche 5b).
Chaque réponse HTTP reçoit un nonce frais (``request.state.csp_nonce``)
injecté dans ``script-src``. Les routes servant du HTML avec des scripts
inline (index, popout, admin, editor-poc, excalidraw, page de partage)
l'injectent dans le balisage via :func:`inject_csp_nonce` — mêmes
emplacements, aucun script déplacé.
Tant que ``'unsafe-inline'`` reste dans la politique (retrait en T5c),
l'injection est inerte : elle prépare la bascule sans changer le
comportement.
"""
from __future__ import annotations
import re
import secrets
# Balises <script> exécutables sans `src` et sans nonce existant :
# `<script>`, `<script type="module">`, `<script type="importmap">`.
# Les blocs non-JS (ex. `type="text/plain"`) et les scripts externes
# (`src=…`, couverts par 'self'/hôtes CDN) sont laissés intacts.
_SCRIPT_TAG_RE = re.compile(
r"<script(?=>|\s+type=\"(?:module|importmap)\"\s*>)",
)
def new_nonce() -> str:
"""Generate a fresh per-response CSP nonce."""
return secrets.token_urlsafe(16)
def inject_csp_nonce(html: str, nonce: str) -> str:
"""Add ``nonce="…"`` to bare executable inline ``<script>`` tags."""
return _SCRIPT_TAG_RE.sub(f'<script nonce="{nonce}"', html)
+38 -9
View File
@@ -167,6 +167,13 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to all HTTP responses."""
async def dispatch(self, request, call_next):
from backend.csp import new_nonce
# Nonce CSP frais par réponse (#87 T5b) : injecté dans script-src et
# dans le balisage HTML par les routes (backend.csp.inject_csp_nonce).
# 'unsafe-inline' est conservé jusqu'en T5c (bascule avec validation E2E).
nonce = new_nonce()
request.state.csp_nonce = nonce
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
@@ -177,7 +184,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
if "Content-Security-Policy" not in response.headers:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
f"script-src 'self' 'unsafe-inline' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
@@ -700,6 +707,15 @@ def _get_backup_dir(vault_name: str, relative_path: str) -> Path:
# Static files & SPA fallback
# ---------------------------------------------------------------------------
def _html_with_nonce(request: Request, name: str) -> str:
"""Read a frontend HTML file and inject the per-response CSP nonce (#87 T5b)."""
from backend.csp import inject_csp_nonce
return inject_csp_nonce(
(FRONTEND_DIR / name).read_text(encoding="utf-8"),
request.state.csp_nonce,
)
if FRONTEND_DIR.exists():
# ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache).
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
@@ -732,23 +748,36 @@ if FRONTEND_DIR.exists():
raise HTTPException(status_code=404, detail="Manifest not found")
@app.get("/popout/{vault_name}/{path:path}")
async def serve_popout(vault_name: str, path: str):
async def serve_popout(request: Request, vault_name: str, path: str):
"""Serve the minimalist popout page for a specific file."""
popout_file = FRONTEND_DIR / "popout.html"
if popout_file.exists():
return HTMLResponse(content=popout_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_html_with_nonce(request, "popout.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Popout template not found")
@app.get("/editor-poc")
async def serve_editor_poc():
async def serve_editor_poc(request: Request):
"""Serve the standalone Editor POC page (multi-zone toolbar demo)."""
poc_file = FRONTEND_DIR / "editor-poc.html"
if poc_file.exists():
return HTMLResponse(content=poc_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_html_with_nonce(request, "editor-poc.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Editor POC not found")
@app.get("/excalidraw-editor.html", include_in_schema=False)
async def serve_excalidraw_editor(request: Request):
"""Serve the Excalidraw editor with CSP nonce (#87 T5b).
Remplace l'accès direct via ``/static/`` (utilisé par l'iframe du
viewer) : sans injection, les scripts inline seraient bloqués dès
le retrait de ``'unsafe-inline'`` (T5c).
"""
exca_file = FRONTEND_DIR / "excalidraw-editor.html"
if exca_file.exists():
return HTMLResponse(content=_html_with_nonce(request, "excalidraw-editor.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Excalidraw editor not found")
@app.get("/admin.html", response_class=HTMLResponse)
async def serve_admin_page(_current_user=Depends(require_admin)):
async def serve_admin_page(request: Request, _current_user=Depends(require_admin)):
"""Serve the admin dashboard page (ROADMAP #71) — admin-gated.
Must be declared BEFORE the SPA catch-all ``/{full_path:path}`` or the
@@ -757,13 +786,13 @@ if FRONTEND_DIR.exists():
"""
admin_file = FRONTEND_DIR / "admin.html"
if admin_file.exists():
return HTMLResponse(content=admin_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_html_with_nonce(request, "admin.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Admin page not found")
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
async def serve_spa(request: Request, full_path: str):
"""Serve the SPA index.html for all non-API routes."""
index_file = FRONTEND_DIR / "index.html"
if index_file.exists():
return HTMLResponse(content=index_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_html_with_nonce(request, "index.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Frontend not found")
+17 -7
View File
@@ -21,7 +21,7 @@ import logging
from pathlib import Path
import frontmatter
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, Response
from backend.auth.middleware import check_vault_access, require_auth
@@ -164,8 +164,10 @@ async def public_share_raw(token: str):
@router.get("/s/{token}", response_class=HTMLResponse)
async def public_share_view(token: str):
async def public_share_view(request: Request, token: str):
"""Public share view — no authentication required."""
from backend.csp import inject_csp_nonce
share = get_share_by_token(token)
if not share:
raise HTTPException(404, "Share not found or expired")
@@ -230,7 +232,9 @@ async def public_share_view(token: str):
if fm_items:
fm_html = f'<div class="fm-section"><div class="fm-header">Frontmatter</div><div class="fm-body">{"".join(fm_items)}</div></div>'
return HTMLResponse(f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
return HTMLResponse(
inject_csp_nonce(
f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title_esc} — ObsiGate Share</title>
<style>
:root {{ --bg:#1a1a2e; --bg-card:#16213e; --text:#e0e0e0; --text-muted:#888; --accent:#6366f1; --border:#2a2a4a; --banner-bg:var(--accent); --banner-text:#fff; }}
@@ -274,15 +278,15 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
</div>
<div class="toolbar">
<span class="toolbar-title">{title_esc}</span>
<button class="toolbar-btn" onclick="toggleTheme()" title="Thème clair/sombre">
<button class="toolbar-btn" data-share-theme title="Thème clair/sombre">
<svg id="theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg id="theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
</button>
<button class="toolbar-btn" onclick="exportMD()" title="Télécharger en Markdown">
<button class="toolbar-btn" data-share-md title="Télécharger en Markdown">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
.md
</button>
<button class="toolbar-btn" onclick="location.href=location.pathname+'/pdf'" title="Télécharger en PDF">
<button class="toolbar-btn" data-share-pdf title="Télécharger en PDF">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
PDF
</button>
@@ -293,4 +297,10 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();
function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").textContent);var b=new Blob([raw],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download={title_download_js};a.click()}}
</script></body></html>""")
document.querySelector("[data-share-theme]").addEventListener("click",toggleTheme);
document.querySelector("[data-share-md]").addEventListener("click",exportMD);
document.querySelector("[data-share-pdf]").addEventListener("click",function(){{location.href=location.pathname+"/pdf"}});
</script></body></html>""",
request.state.csp_nonce,
),
)
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.28.6"
version = "2.28.7"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.28.6"
version = "2.28.7"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.28.6",
"version": "2.28.7",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+2 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.28.6 | **Dernière mise à jour :** 2026-09-26
> **Version :** 2.28.7 | **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) — **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 `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)
- [ ] 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)
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
---
+1 -1
View File
@@ -35,7 +35,7 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
// Build the iframe
const iframe = document.createElement('iframe');
iframe.id = editorId;
iframe.src = '/static/excalidraw-editor.html?v=' + Date.now();
iframe.src = '/excalidraw-editor.html?v=' + Date.now();
iframe.sandbox.add('allow-scripts');
iframe.sandbox.add('allow-same-origin');
// Let the editor's own Fullscreen button work (native Fullscreen API inside
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.28.6",
"version": "2.28.7",
"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": {
+3 -2
View File
@@ -100,7 +100,7 @@ await test("module exports renderExcalidraw + helpers", () => {
assert.equal(typeof destroyExcalidrawEditor, "function");
});
await test("renderExcalidraw creates an iframe with sandbox + static src", () => {
await test("renderExcalidraw creates an iframe with sandbox + routed src", () => {
const container = document.getElementById("content-area");
const data = {
is_excalidraw: true,
@@ -111,7 +111,8 @@ await test("renderExcalidraw creates an iframe with sandbox + static src", () =>
renderExcalidraw(container, data, "TestVault", "diagram.excalidraw");
const iframe = container.querySelector("iframe");
assert.ok(iframe, "iframe should be created");
assert.ok(iframe.src.includes("/static/excalidraw-editor.html"), `src: ${iframe.src}`);
assert.ok(iframe.src.includes("/excalidraw-editor.html"), `src: ${iframe.src}`);
assert.ok(!iframe.src.includes("/static/excalidraw-editor.html"), `route avec nonce CSP: ${iframe.src}`);
assert.ok(iframe.sandbox.contains("allow-scripts"), "sandbox allow-scripts");
assert.ok(iframe.sandbox.contains("allow-same-origin"), "sandbox allow-same-origin");
assert.match(iframe.style.cssText, /100%/);
+82
View File
@@ -0,0 +1,82 @@
"""Tests — nonces CSP (ROADMAP #87 T5b).
- `inject_csp_nonce` ne touche que les scripts inline exécutables
(`<script>`, `type="module"` / `type="importmap"` sans `src`), jamais les
blocs de données (`type="text/plain"`) ni les scripts externes.
- Chaque page HTML servie avec des scripts inline les porte tous avec un
nonce après injection.
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
NONCE = "TESTNONCE1234567890"
def _read(name: str) -> str:
return (ROOT / "frontend" / name).read_text(encoding="utf-8")
def test_inject_only_bare_executable_scripts():
from backend.csp import inject_csp_nonce
html = (
"<script>var a = 1;</script>"
'<script type="module">import x from "y";</script>'
'<script type="importmap">{"imports": {}}</script>'
'<script type="module" src="/static/js/app.js"></script>'
'<script src="https://cdnjs.cloudflare.com/x.js"></script>'
'<script id="raw-content" type="text/plain">hello</script>'
'<script nonce="OLD">var b = 2;</script>'
)
out = inject_csp_nonce(html, NONCE)
assert out.count(f'nonce="{NONCE}"') == 3
assert '<script src="https://cdnjs.cloudflare.com/x.js">' in out
assert '<script id="raw-content" type="text/plain">' in out
assert '<script nonce="OLD">' in out
def test_new_nonce_unique_per_call():
from backend.csp import new_nonce
assert new_nonce() != new_nonce()
def test_all_pages_fully_nonced():
"""Aucun script inline exécutable sans nonce après injection (sauf src=)."""
from backend.csp import inject_csp_nonce
for name in ("index.html", "popout.html", "admin.html", "editor-poc.html", "excalidraw-editor.html"):
out = inject_csp_nonce(_read(name), NONCE)
bare = re.findall(r"<script>", out)
assert not bare, f"{name} : scripts sans nonce restants"
inline_mods = [m for m in re.findall(r'<script type="(?:module|importmap)">', out)]
assert not inline_mods, f"{name} : modules/importmap sans nonce restants"
def _nonce_of(csp: str) -> str | None:
m = re.search(r"'nonce-([^']+)'", csp or "")
return m.group(1) if m else None
def test_nonce_header_fresh_per_response(client):
"""Chaque réponse porte un nonce frais dans `script-src`."""
r1 = client.get("/")
r2 = client.get("/")
n1, n2 = _nonce_of(r1.headers.get("content-security-policy")), _nonce_of(
r2.headers.get("content-security-policy")
)
assert n1 and n2 and n1 != n2
def test_nonce_matches_injected_html(client):
"""Le nonce de l'en-tête est celui injecté dans le HTML (`/`, excalidraw)."""
for path in ("/", "/excalidraw-editor.html"):
resp = client.get(path)
assert resp.status_code == 200, path
nonce = _nonce_of(resp.headers.get("content-security-policy"))
assert nonce, path
assert f'nonce="{nonce}"' in resp.text, path