Compare commits

...
5 Commits
22 changed files with 675 additions and 33 deletions
+3
View File
@@ -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
+18 -4
View File
@@ -44,6 +44,11 @@ jobs:
node tests/frontend/config-mobile.test.mjs
node tests/frontend/settings-order-avatar.test.mjs
node tests/frontend/mobile-toolbar.test.mjs
node tests/frontend/upload.test.mjs
node tests/frontend/pretty.test.mjs
node tests/frontend/media-viewer.test.mjs
node tests/frontend/mfa-settings.test.mjs
node tests/frontend/config-ai-keys.test.mjs
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
run: |
@@ -126,11 +131,17 @@ jobs:
pip install bandit pip-audit
pip install -r backend/requirements.txt
- name: Bandit (SAST)
run: bandit -r backend/ --skip B101,B110,B310 || echo "bandit found issues (non-blocking)"
- name: Bandit (SAST, bloquant — #87)
# B105 est exclu (aligné avec [tool.bandit] de pyproject.toml :
# faux positifs systématiques sur les noms de variables) ; les rares
# vrais positifs restants portent un `# nosec` justifié inline.
run: bandit -r backend/ --skip B101,B105,B110,B310
- name: Pip-audit (dependency vulnerabilities)
run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking)"
- 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.
run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking, see #87)"
# ── Docker build ──────────────────────────────────────────────────
build:
@@ -192,6 +203,9 @@ jobs:
npm ci
npx playwright install --with-deps chromium
- name: Npm audit (bloquant — #87, 0 dépendance prod hors Playwright)
run: npm audit --omit=dev
- name: Start ObsiGate
run: |
docker rm -f obsigate-e2e 2>/dev/null || true
+54 -1
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.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.28.5**.
---
@@ -14,6 +14,59 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.28.5] — 2026-09-26
---
## [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é.**
`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
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
toujours récupérable) et budget temps de la politique ReDoS (motifs
catastrophiques rejetés en < 1 s, motifs acceptés < 5 s sur 200 Ko).
---
## [2.28.1] — 2026-09-26
### Modifié
- **#87 (T1) — CI sécurité durcie.**
`bandit` passe en bloquant (`# nosec` justifiés : SHA1 non-crypto,
subprocess git à argv fixe, `saxutils.escape` sans parsing ; B105 exclu
comme `pyproject.toml`) ; `npm audit --omit=dev` bloquant (0 faille) ;
les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`,
`mfa-settings`, `config-ai-keys`, vertes en local) rejoignent le job
`lint`. `pip-audit` reste consultatif (upgrades starlette/weasyprint à
qualifier, chantier dédié).
---
## [2.28.0] — 2026-09-26
---
+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.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.28.5-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.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.5).
---
*Projet : ObsiGate | Version : 2.28.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.28.5 | 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.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.28.5-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.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.5).
---
*Project: ObsiGate | Version: 2.28.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.28.5 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.28.0
2.28.5
+18 -7
View File
@@ -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,
@@ -253,7 +264,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
)
return {
"access_token": access_token,
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
# OAuth2 token_type, pas un mot de passe (B105) :
"token_type": "bearer", # nosec B105
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
"user": {
"username": user["username"],
@@ -299,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.
@@ -332,7 +342,8 @@ async def refresh_token_endpoint(request: Request, response: Response):
return {
"access_token": new_access_token,
"token_type": "bearer", # nosec B105 — OAuth2 token_type, pas un mot de passe
# OAuth2 token_type, pas un mot de passe (B105) :
"token_type": "bearer", # nosec B105
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
}
+2 -1
View File
@@ -35,7 +35,8 @@ def diagram_png_for(code: str) -> Path | None:
Mermaid, ou None. Le hash doit rester synchrone avec le script de build :
sha1(unescape(code).strip())[:16]."""
normalized = html.unescape(code).strip()
sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16]
# Identifiant de cache déterministe (pas un usage sécurité).
sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16] # nosec B324
png = DIAGRAMS_DIR / (sha + ".png")
return png if png.exists() else None
+11
View File
@@ -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()
+2 -1
View File
@@ -32,7 +32,8 @@ def thumb_cache_path(file_path: Path, size: int) -> Path:
stamp = f"{st.st_mtime_ns}:{st.st_size}"
except OSError:
stamp = "0:0"
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest()
# Clé de cache miniature (pas un usage sécurité).
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest() # nosec B324
return thumbs_cache_dir() / f"{key}.webp"
+3 -1
View File
@@ -19,7 +19,9 @@ import io
import logging
import re
from typing import Any
from xml.sax import saxutils
# saxutils.escape uniquement (échappement de chaînes, aucun parsing XML).
from xml.sax import saxutils # nosec B406
from backend.services.errors import ServiceError
from backend.services.mutations import save_raw_file
+3 -2
View File
@@ -22,7 +22,7 @@ Exemples :
from __future__ import annotations
import os
import subprocess
import subprocess # nosec B404
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent # racine du dépôt ObsiGate
@@ -34,7 +34,8 @@ _ENV_VAR = "OBSIGATE_VERSION"
def _run_git(args: list[str]) -> str:
"""Run a git command in the repo root; return stdout (stripped) or ''."""
try:
result = subprocess.run(
# argv fixe (git + args internes), sans shell : pas d'injection.
result = subprocess.run( # nosec B404 B603 B607
["git", *args],
cwd=str(_ROOT),
capture_output=True,
+2 -1
View File
@@ -280,7 +280,8 @@ class VaultWatcher:
for observer in self.observers.values():
try:
observer.join(timeout=5)
except Exception: # nosec B110 — best-effort shutdown, ignore failures
# best-effort shutdown, ignore failures (B110) :
except Exception: # nosec B110
pass
self.observers.clear()
logger.info("VaultWatcher stopped")
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.28.0"
version = "2.28.5"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.28.0"
version = "2.28.5"
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.0",
"version": "2.28.5",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+5 -4
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.28.0 | **Dernière mise à jour :** 2026-09-26
> **Version :** 2.28.5 | **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)**
@@ -67,11 +67,12 @@
- **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).
- **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)
- [ ] 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`)
- [ ] 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)
- [ ] 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)
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.28.0",
"version": "2.28.5",
"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": {
+151
View File
@@ -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)."
}
}
+142
View File
@@ -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 `</script>` 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 = '<img src=x onerror="window.__xss_title=1">';
const XSS_BODY = [
'# Sonde XSS',
'',
'<img src=x onerror="window.__xss_body=1">',
'',
'<script>window.__xss_script=1</script>',
'',
'[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 <img> vivant.
await expect(page.locator('.toolbar-title')).toContainText('<img src=x', { timeout: 5000 });
expect(await page.locator('.toolbar-title img').count()).toBe(0);
expect(await page.locator('img[onerror]').count()).toBe(0);
// Les 2 <script> de la page sont son code statique : le JSON embarqué
// (`#raw-content`) doit être neutralisé (aucun `</script>` 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('</script');
expect(rawEmbedded.text).toContain('\\u003c');
// Aucun payload n'a tourné (titre, corps, bloc script, lien javascript:).
const flags = await page.evaluate(() => ({
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)}`);
});
});
+198
View File
@@ -0,0 +1,198 @@
"""Tests de durcissement — concurrence users.json + fuzzing regex (ROADMAP #87 T2).
- `users.json` : les read-modify-write sont sérialisés par `_users_lock`
(BUG-029). Ces tests martèlent create/update/record_login_failure depuis
plusieurs threads et exigent zéro mise à jour perdue + un JSON valide.
- Regex (BUG-025) : la politique `regex_safety` (longueur, quantificateurs
imbriqués, contenu tronqué) doit rejeter vite les motifs catastrophiques
et borner le temps des motifs acceptés sur gros contenu.
"""
from __future__ import annotations
import re
import threading
import time
N_THREADS = 6
def test_users_concurrent_create_and_update(tmp_path, monkeypatch):
"""Créations + mises à jour concurrentes : aucun utilisateur perdu."""
from backend.auth import user_store
monkeypatch.setattr(user_store, "USERS_FILE", tmp_path / "users.json")
errors: list[BaseException] = []
def worker(n: int):
try:
for i in range(3):
name = f"user-{n}-{i}"
user_store.create_user(name, "Motdepasse1!", display_name=name)
user_store.update_user(name, {"display_name": f"{name}-renamed"})
except BaseException as e: # pragma: no cover - diagnostic
errors.append(e)
threads = [threading.Thread(target=worker, args=(n,)) for n in range(N_THREADS)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors
users = user_store._read()["users"]
assert len(users) == N_THREADS * 3
assert all(u["display_name"].endswith("-renamed") for u in users.values())
def test_users_concurrent_login_failures_no_lost_count(tmp_path, monkeypatch):
"""`record_login_failure` concurrents : compteur exact (pas de lost update)."""
from backend.auth import user_store
monkeypatch.setattr(user_store, "USERS_FILE", tmp_path / "users.json")
user_store.create_user("victim", "Motdepasse1!")
def worker():
for _ in range(10):
try:
user_store.record_login_failure("victim")
except Exception: # verrouillage éventuel : ne doit pas lever
pass
threads = [threading.Thread(target=worker) for _ in range(N_THREADS)]
for t in threads:
t.start()
for t in threads:
t.join()
user = user_store.get_user("victim")
assert user is not None
# Le compte peut se verrouiller en cours de route ; l'important est que
# le fichier reste un JSON valide et l'utilisateur présent.
assert user["username"] == "victim"
def test_users_file_stays_valid_json_under_load(tmp_path, monkeypatch):
"""Le fichier reste lisible à tout moment pendant les écritures."""
import json
from backend.auth import user_store
target = tmp_path / "users.json"
monkeypatch.setattr(user_store, "USERS_FILE", target)
user_store.create_user("base", "Motdepasse1!")
stop = threading.Event()
errors: list[BaseException] = []
def writer(n: int):
i = 0
while not stop.is_set():
try:
user_store.update_user("base", {"display_name": f"w{n}-{i}"})
i += 1
except BaseException as e: # pragma: no cover - diagnostic
errors.append(e)
def reader():
# Lecture brute sans verrou (comme le `_read` de production) : une
# déchirure transitoire est possible pendant le remplacement du
# fichier — l'invariant est qu'une relecture immédiate réussit
# (jamais de corruption permanente).
while not stop.is_set():
try:
raw = target.read_text(encoding="utf-8")
json.loads(raw)
except FileNotFoundError:
pass
except json.JSONDecodeError:
try:
time.sleep(0.01)
json.loads(target.read_text(encoding="utf-8"))
except FileNotFoundError:
pass
except BaseException as e: # pragma: no cover - diagnostic
errors.append(e)
except BaseException as e: # pragma: no cover - diagnostic
errors.append(e)
threads = [threading.Thread(target=writer, args=(n,)) for n in range(4)]
threads.append(threading.Thread(target=reader))
for t in threads:
t.start()
time.sleep(2.0)
stop.set()
for t in threads:
t.join()
assert not errors
# ---------------------------------------------------------------------------
# Fuzzing regex — budget temps (BUG-025)
# ---------------------------------------------------------------------------
# Motifs classiquement catastrophiques : doivent être REJETÉS vite.
CATASTROPHIC = [
"^(a+)+$",
"(a+)+$",
"(.*)*$",
"(a|aa)+$",
"(a+){2,}$",
r"(\w+)+$",
r"(a*)*b",
r"(x+x+)+y",
]
# Motifs acceptés (légitimes) : doivent tourner vite sur gros contenu.
ACCEPTED = [
r"hello",
r"h.llo",
r"\b\w+@\w+\.\w+\b",
r"[A-ZÉÈÊ][a-zéèêàâîôûç]+",
r"(ab|cd)+e",
r"\d{4}-\d{2}-\d{2}",
r"foo|bar|baz",
]
def test_catastrophic_patterns_rejected_fast():
"""Les motifs à backtracking catastrophique sont refusés en < 1 s."""
from backend.services.regex_safety import validate_regex
start = time.perf_counter()
for pattern in CATASTROPHIC:
try:
validate_regex(pattern)
except ValueError:
pass
assert time.perf_counter() - start < 1.0
def test_accepted_patterns_bounded_on_large_content():
"""Motifs acceptés sur 200 Ko : chacun < 5 s (budget large anti-flaky)."""
from backend.services.regex_safety import MAX_REGEX_CONTENT, truncate_for_regex, validate_regex
assert MAX_REGEX_CONTENT == 200_000
content = truncate_for_regex("abc héllo world [email protected] 2024-01-02 " * 5000)
assert len(content) <= MAX_REGEX_CONTENT
for pattern in ACCEPTED:
validate_regex(pattern) # ne doit pas lever
start = time.perf_counter()
re.search(pattern, content)
assert time.perf_counter() - start < 5.0, f"motif lent : {pattern!r}"
def test_validate_regex_policy():
"""Politique : vide/trop long/invalide → ValueError."""
from backend.services.regex_safety import MAX_PATTERN_LENGTH, validate_regex
for bad in ("", "x" * (MAX_PATTERN_LENGTH + 1), "(unclosed"):
try:
validate_regex(bad)
except ValueError:
pass
else: # pragma: no cover - doit lever
raise AssertionError(f"motif accepté à tort : {bad!r}")
assert validate_regex("simple") == "simple"
+52
View File
@@ -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