test: #87 T4 E2E XSS partage et lecteur + script serveur E2E avec progression
This commit is contained in:
@@ -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)}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user