diff --git a/CHANGELOG.md b/CHANGELOG.md
index 58c8605..f5a16d5 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.3**.
+> [Unreleased](#unreleased). La dernière version livrée est **2.28.4**.
---
@@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
+## [2.28.4] — 2026-09-26
+
+---
+
## [2.28.3] — 2026-09-26
---
@@ -22,6 +26,13 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Ajouté
+- **#87 (T4) — E2E XSS et serveur E2E piloté.**
+ `tests/e2e/xss.spec.js` : page publique `/s/{token}` (titre/frontmatter
+ hostile échappé, JSON neutralisé, aucun JS exécuté) et lecteur markdown
+ (sanitizer, aucun `on*`/`javascript:` vivant) — 2/2 verts en local.
+ `scripts/e2e-server.ps1` : `start|stop|status|logs` avec progression
+ visible et fichier PID (fini les serveurs orphelins sur le port 2029).
+
- **#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
diff --git a/README.fr.md b/README.fr.md
index 7cd40d9..0b88511 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.
-[]()
+[]()
[](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.3).
+Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.4).
---
-*Projet : ObsiGate | Version : 2.28.3 | Dernière mise à jour : Septembre 2026*
+*Projet : ObsiGate | Version : 2.28.4 | Dernière mise à jour : Septembre 2026*
diff --git a/README.md b/README.md
index 1c7bc7c..952eddc 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.
-[]()
+[]()
[](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.3).
+See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.4).
---
-*Project: ObsiGate | Version: 2.28.3 | Last updated: September 2026*
+*Project: ObsiGate | Version: 2.28.4 | Last updated: September 2026*
diff --git a/VERSION b/VERSION
index 1eb56ea..d422cdf 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.28.3
+2.28.4
diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock
index 43e80e0..2ae2140 100644
--- a/desktop/Cargo.lock
+++ b/desktop/Cargo.lock
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
-version = "2.28.3"
+version = "2.28.4"
dependencies = [
"chrono",
"env_logger",
diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml
index 9b2de3e..c08a312 100644
--- a/desktop/Cargo.toml
+++ b/desktop/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
-version = "2.28.3"
+version = "2.28.4"
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 57873c9..3486347 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.3",
+ "version": "2.28.4",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 728a79d..c1bf5b5 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
-> **Version :** 2.28.3 | **Dernière mise à jour :** 2026-09-26
+> **Version :** 2.28.4 | **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)**
@@ -70,7 +70,7 @@
- **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).
- **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)
+ - [ ] 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 en local) + `scripts/e2e-server.ps1` (cycle de vie serveur E2E avec progression `start|stop|status|logs`)
- [ ] 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)
- [ ] 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 2f8f549..f891190 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "obsigate",
- "version": "2.28.3",
+ "version": "2.28.4",
"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/scripts/e2e-server.ps1 b/scripts/e2e-server.ps1
new file mode 100644
index 0000000..08aa300
--- /dev/null
+++ b/scripts/e2e-server.ps1
@@ -0,0 +1,151 @@
+<#
+.SYNOPSIS
+ ObsiGate — cycle de vie du serveur E2E local, avec progression visible.
+
+.DESCRIPTION
+ Remplace le one-liner opaque de démarrage : chaque étape affiche sa
+ progression (port, PID, attente du health check seconde par seconde,
+ version servie). Memes conditions que le job CI `e2e` et que
+ `scripts/run-e2e-local.ps1` : uvicorn natif, auth désactivée, fixtures
+ TestVault/TestDir, port 2029.
+
+ Le PID est persisté dans `data/e2e-server.pid` pour un arrêt propre
+ (`stop`) — plus de serveurs orphelins qui squattent le port.
+
+.EXAMPLE
+ ./scripts/e2e-server.ps1 start # démarre + attend READY (défaut)
+ ./scripts/e2e-server.ps1 status # port, PID, version servie
+ ./scripts/e2e-server.ps1 logs # queues des logs serveur
+ ./scripts/e2e-server.ps1 stop # arrête le serveur + libère le port
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Position = 0)]
+ [ValidateSet("start", "stop", "status", "logs")]
+ [string]$Command = "start",
+
+ [string]$Port = $(if ($env:E2E_PORT) { $env:E2E_PORT } else { "2029" })
+)
+
+$ErrorActionPreference = "Stop"
+$Root = Split-Path -Parent $PSScriptRoot
+Set-Location -LiteralPath $Root
+
+$BaseUrl = "http://127.0.0.1:$Port"
+$Python = ".\.venv\Scripts\python.exe"
+$PidFile = "data/e2e-server.pid"
+$OutLog = "data/e2e-server.log"
+$ErrLog = "data/e2e-server.err.log"
+
+function Get-PortOwner {
+ $conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ if (-not $conn) { return $null }
+ $proc = Get-Process -Id $conn.OwningProcess -ErrorAction SilentlyContinue
+ return @{ Pid = $conn.OwningProcess; Name = $(if ($proc) { $proc.ProcessName } else { "?" }) }
+}
+
+function Stop-Server {
+ param([string]$Why = "")
+ $killed = @()
+ if (Test-Path -LiteralPath $PidFile) {
+ $srvPid = (Get-Content -LiteralPath $PidFile -TotalCount 1).Trim()
+ if ($srvPid -match '^\d+$') {
+ Stop-Process -Id $srvPid -Force -ErrorAction SilentlyContinue
+ $killed += $srvPid
+ }
+ Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue
+ }
+ $owner = Get-PortOwner
+ if ($owner) {
+ Stop-Process -Id $owner.Pid -Force -ErrorAction SilentlyContinue
+ $killed += $owner.Pid
+ }
+ if ($killed.Count) { Write-Host "[OK] Serveur arrêté (PID $($killed -join ', ')). $Why" }
+ else { Write-Host "[OK] Aucun serveur en cours (port $Port libre)." }
+}
+
+switch ($Command) {
+ "stop" {
+ Write-Host "[1/1] Arrêt du serveur E2E (port $Port)..."
+ Stop-Server
+ }
+
+ "status" {
+ $owner = Get-PortOwner
+ if (-not $owner) { Write-Host "[INFO] Port $Port libre, aucun serveur."; break }
+ Write-Host "[INFO] Port $Port occupé par PID $($owner.Pid) ($($owner.Name))."
+ try {
+ $health = Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 5 -UseBasicParsing |
+ Select-Object -ExpandProperty Content | ConvertFrom-Json
+ Write-Host "[OK] Health 200 — version $($health.version), $($health.total_files) fichiers indexés."
+ } catch {
+ Write-Host "[WARN] Processus présent mais health injoignable : $($_.Exception.Message)"
+ }
+ }
+
+ "logs" {
+ Write-Host "===== $OutLog (stdout) ====="
+ Get-Content -LiteralPath $OutLog -Tail 15 -ErrorAction SilentlyContinue
+ Write-Host "===== $ErrLog (stderr) ====="
+ Get-Content -LiteralPath $ErrLog -Tail 25 -ErrorAction SilentlyContinue
+ }
+
+ "start" {
+ Write-Host "[1/4] Port $Port..."
+ $owner = Get-PortOwner
+ if ($owner) {
+ Write-Host "[ERR] Port $Port déjà occupé par PID $($owner.Pid) ($($owner.Name))."
+ Write-Host " Lancez d'abord : ./scripts/e2e-server.ps1 stop"
+ exit 1
+ }
+ Write-Host " libre."
+
+ Write-Host "[2/4] Interpréteur $Python..."
+ if (-not (Test-Path -LiteralPath $Python)) {
+ Write-Host "[ERR] $Python introuvable. Créez le venv (voir AGENTS.md)."
+ exit 1
+ }
+ Write-Host " présent."
+ New-Item -ItemType Directory -Force -Path "data" | Out-Null
+
+ Write-Host "[3/4] Démarrage uvicorn (auth désactivée, TestVault/TestDir)..."
+ $env:OBSIGATE_AUTH_ENABLED = "false"
+ $env:VAULT_1_NAME = "TestVault"
+ $env:VAULT_1_PATH = (Resolve-Path -LiteralPath "test_vault").Path
+ $env:DIR_1_NAME = "TestDir"
+ $env:DIR_1_PATH = (Resolve-Path -LiteralPath "test_dir").Path
+ $server = Start-Process -FilePath $Python `
+ -ArgumentList "-m", "uvicorn", "backend.main:app", "--host", "127.0.0.1", "--port", $Port `
+ -RedirectStandardOutput $OutLog -RedirectStandardError $ErrLog `
+ -PassThru -WindowStyle Hidden
+ $server.Id | Set-Content -LiteralPath $PidFile
+ Write-Host " PID $($server.Id) (logs : $OutLog / $ErrLog)."
+
+ Write-Host "[4/4] Attente du health check (30 s max)..."
+ $ready = $false
+ for ($i = 1; $i -le 30; $i++) {
+ try {
+ Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 2 -UseBasicParsing | Out-Null
+ $ready = $true
+ break
+ } catch {
+ if ($server.HasExited) {
+ Write-Host "[ERR] Le serveur a quitté (code $($server.ExitCode)). Fin du log :"
+ Get-Content -LiteralPath $ErrLog -Tail 15 -ErrorAction SilentlyContinue
+ exit 1
+ }
+ if ($i % 5 -eq 0) { Write-Host " ... $i/30 s (indexation en cours, voir $ErrLog)" }
+ Start-Sleep -Seconds 1
+ }
+ }
+ if (-not $ready) {
+ Write-Host "[ERR] Injoignable après 30 s. Fin du log :"
+ Get-Content -LiteralPath $ErrLog -Tail 15 -ErrorAction SilentlyContinue
+ exit 1
+ }
+ $health = Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 5 -UseBasicParsing |
+ Select-Object -ExpandProperty Content | ConvertFrom-Json
+ Write-Host "[OK] READY — ObsiGate v$($health.version) sur $BaseUrl ($($health.total_files) fichiers)."
+ }
+}
diff --git a/tests/e2e/xss.spec.js b/tests/e2e/xss.spec.js
new file mode 100644
index 0000000..68022f4
--- /dev/null
+++ b/tests/e2e/xss.spec.js
@@ -0,0 +1,142 @@
+/**
+ * E2E tests — XSS stocké : page publique de partage + lecteur markdown (#87 T4).
+ *
+ * Non-régression BUG-021 (sanitizer serveur du rendu markdown) et BUG-022
+ * (échappement `title`/frontmatter + neutralisation `` sur `/s/{token}`).
+ * Chaque test arme un guetteur de dialogues `alert` : le moindre JS exécuté
+ * fait échouer le test, en plus des assertions DOM (contenu échappé, aucun
+ * attribut `on*` vivant).
+ *
+ * Run (local, instance de test port 2029, auth désactivée — cf. scripts/run-e2e-local.ps1) :
+ * BASE_URL=http://localhost:2029 npx playwright test tests/e2e/xss.spec.js --project=chromium-desktop
+ */
+
+import { test, expect } from '@playwright/test';
+
+const BASE = process.env.BASE_URL || 'http://localhost:2029';
+const VAULT = 'TestVault';
+const XSS_FILE = 'e2e-xss-probe.md';
+const XSS_TITLE = '
';
+const XSS_BODY = [
+ '# Sonde XSS',
+ '',
+ '
',
+ '',
+ '',
+ '',
+ '[xss](javascript:window.__xss_js=1)',
+].join('\n');
+
+async function api(request, method, path, data) {
+ const resp = await request.fetch(`${BASE}${path}`, {
+ method,
+ data,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ if (!resp.ok()) {
+ throw new Error(`${method} ${path} → ${resp.status()} ${await resp.text()}`);
+ }
+ return resp.json();
+}
+
+async function precleanProbeFile(request) {
+ // Idempotence : un run précédent interrompu a pu laisser le fichier sonde.
+ await request.fetch(`${BASE}/api/file/${VAULT}?path=${encodeURIComponent(XSS_FILE)}`, {
+ method: 'DELETE',
+ }).catch(() => {});
+}
+
+async function armAlertTrap(page) {
+ const dialogs = [];
+ page.on('dialog', async (d) => {
+ dialogs.push(d.message());
+ await d.dismiss();
+ });
+ return dialogs;
+}
+
+async function openFile(page, vault, filePath) {
+ const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
+ if (!(await treeItem.count())) {
+ await page.locator(`.tree-item.vault-item[data-vault="${vault}"]`).first().click();
+ await treeItem.waitFor({ state: 'attached', timeout: 8000 });
+ }
+ await treeItem.dblclick({ timeout: 5000 });
+}
+
+test.describe('XSS — page publique de partage (/s/{token}, BUG-022)', () => {
+ test('un titre/frontmatter hostile est échappé et aucun script ne tourne', async ({ page, request }) => {
+ const dialogs = await armAlertTrap(page);
+
+ await precleanProbeFile(request);
+ await api(request, 'POST', `/api/file/${VAULT}`, {
+ path: XSS_FILE,
+ // Titre entre quotes simples YAML (les doubles quotes internes restent
+ // des caractères ordinaires et arrivent intactes au backend).
+ content: `---\ntitle: '${XSS_TITLE}'\n---\n\n${XSS_BODY}\n`,
+ });
+ const share = await api(request, 'POST', `/api/share/${VAULT}`, { path: XSS_FILE });
+
+ await page.goto(`${BASE}/s/${share.token}`);
+ await expect(page.locator('.share-banner')).toBeVisible({ timeout: 10000 });
+
+ // Le titre affiché est le texte brut (balise neutralisée), pas un
vivant.
+ await expect(page.locator('.toolbar-title')).toContainText('
de la page sont son code statique : le JSON embarqué
+ // (`#raw-content`) doit être neutralisé (aucun `` littéral).
+ const rawEmbedded = await page.evaluate(() => {
+ const el = document.getElementById('raw-content');
+ return { text: el ? el.textContent : null };
+ });
+ expect(rawEmbedded.text).not.toBeNull();
+ expect(rawEmbedded.text).not.toContain(' ({
+ title: window.__xss_title,
+ body: window.__xss_body,
+ script: window.__xss_script,
+ js: window.__xss_js,
+ }));
+ expect(flags).toEqual({ title: undefined, body: undefined, script: undefined, js: undefined });
+ expect(dialogs).toEqual([]);
+
+ await api(request, 'DELETE', `/api/share/${share.id}`);
+ await api(request, 'DELETE', `/api/file/${VAULT}?path=${encodeURIComponent(XSS_FILE)}`);
+ });
+});
+
+test.describe('XSS — lecteur markdown (BUG-021)', () => {
+ test('le HTML injecté dans une note est neutralisé à l\'affichage', async ({ page, request }) => {
+ const dialogs = await armAlertTrap(page);
+
+ await precleanProbeFile(request);
+ await api(request, 'POST', `/api/file/${VAULT}`, { path: XSS_FILE, content: `${XSS_BODY}\n` });
+
+ await page.goto(BASE);
+ await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
+ await openFile(page, VAULT, XSS_FILE);
+
+ const content = page.locator('#content-area');
+ await expect(content).toContainText('Sonde XSS', { timeout: 10000 });
+
+ // Le sanitizer serveur a retiré les vecteurs : pas d'onerror, pas de script,
+ // pas de lien javascript: exécutable dans la zone de lecture.
+ expect(await content.locator('img[onerror]').count()).toBe(0);
+ expect(await content.locator('script').count()).toBe(0);
+ expect(await content.locator('a[href^="javascript:"]').count()).toBe(0);
+
+ const flags = await page.evaluate(() => ({
+ body: window.__xss_body,
+ script: window.__xss_script,
+ js: window.__xss_js,
+ }));
+ expect(flags).toEqual({ body: undefined, script: undefined, js: undefined });
+ expect(dialogs).toEqual([]);
+
+ await api(request, 'DELETE', `/api/file/${VAULT}?path=${encodeURIComponent(XSS_FILE)}`);
+ });
+});