Compare commits

..
2 Commits
15 changed files with 410 additions and 20 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
+23 -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.2**.
> [Unreleased](#unreleased). La dernière version livrée est **2.28.4**.
---
@@ -14,10 +14,32 @@ 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é.**
`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
+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.2-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.2).
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.2 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.28.4 | 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.2-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.2).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.4).
---
*Project: ObsiGate | Version: 2.28.2 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.28.4 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.28.2
2.28.4
+14 -5
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,
@@ -300,9 +311,7 @@ async def refresh_token_endpoint(request: Request, response: Response):
if stale:
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
import os
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
secure = is_secure_cookies()
remember_me = bool(payload.get("remember", False))
# BUG-027: rotate the refresh token — the old one is now single-use.
+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()
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.28.2"
version = "2.28.4"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.28.2"
version = "2.28.4"
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.2",
"version": "2.28.4",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+3 -3
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.28.2 | **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,9 +70,9 @@
- **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)
- [ ] 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.2",
"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": {
+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)}`);
});
});
+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