From 922dfa2e79b1248a838c9338ba943b60fe4f9c5e Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 26 Sep 2026 21:46:13 -0400 Subject: [PATCH] test: #87 T4 E2E XSS partage et lecteur + script serveur E2E avec progression --- CHANGELOG.md | 13 +++- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 4 +- package.json | 2 +- scripts/e2e-server.ps1 | 151 ++++++++++++++++++++++++++++++++++++++++ tests/e2e/xss.spec.js | 142 +++++++++++++++++++++++++++++++++++++ 11 files changed, 318 insertions(+), 14 deletions(-) create mode 100644 scripts/e2e-server.ps1 create mode 100644 tests/e2e/xss.spec.js 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. -[![Version](https://img.shields.io/badge/Version-2.28.3-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.28.4-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.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. -[![Version](https://img.shields.io/badge/Version-2.28.3-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.28.4-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.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)}`); + }); +});