/** * 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)}`); }); });