test(loadtest): an event simulation on a real 2 vCPU / 4 GB / 30 GB box
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m2s
Audit / npm audit (frontend) (push) Successful in 51s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 52s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m41s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m5s
Checks / E2E — typecheck + lint (push) Failing after 39s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 9m13s
E2E / Cross-UA smoke matrix (push) Failing after 4m20s
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m2s
Audit / npm audit (frontend) (push) Successful in 51s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 52s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m41s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m5s
Checks / E2E — typecheck + lint (push) Failing after 39s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 9m13s
E2E / Cross-UA smoke matrix (push) Failing after 4m20s
`driver.mjs` is a pipeline benchmark: synthetic images, uniform load, rate limits off, and — the part that mattered — an unconstrained host, so the 1 GB app cap was never exercised and the disk gate never fired. It could not have found either of the two defects fixed in the preceding commits. This harness differs in three ways that earn their keep: REAL CONTENT. Uploads come from a pool of actual wedding photos and videos, unedited, including the HEIC files and 25 MB frames the app is supposed to REFUSE. Those refusals are the test, not noise to filter out — 152 of 932 attempts were refused, and the breakdown of WHY is the most actionable output. PERSONAS. ~100 viewers and ~50 uploaders across nine behaviour profiles, six device profiles, each with a join time and a session length. A casual guest who posts four photos generates a completely different request mix than a photographer dumping 130, and both differ from a kiosk holding one SSE stream all night. A 37-case abuse suite covers malicious payloads, injection, cross-user tampering, enumeration and the rate limiters. RATE LIMITS STAY ON. `driver.mjs` disabled them because it ran every guest from one IP. Almost every limit that matters is per USER, not per IP, and those are as real for 150 synthetic sessions as for 150 phones — leaving them on is what lets the abuse personas prove the defences work. The per-IP limits ARE distorted by the single source address; that distortion is measured and reported rather than configured away. `docker-compose.sim.yml` reproduces the CX22 rather than asserting it: production's per-service cpus/memory/cpu_shares verbatim, every container pinned to the SAME two cores with `cpuset` so they genuinely contend, and a real loopback ext4 volume so the app's own statvfs returns true numbers. Run the driver under `taskset` onto other cores, or the load generator competes with the thing it measures. `browser-check.mjs` covers what an HTTP driver structurally cannot: the SvelteKit container, and whether the frontend ESCAPES the XSS caption the backend stores verbatim. The backend stores captions raw by design, so the renderer is the entire defence — and only a browser can prove the payload is inert. It reports INCONCLUSIVE rather than PASS when the payload never reached the DOM, because a check that renders nothing proves nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -34,6 +34,12 @@ e2e/.env.test
|
|||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
|
|
||||||
|
# Load-test sample media. The wedding sample set is ~8.7 GB of real photos and
|
||||||
|
# videos; it is input to e2e/loadtest/event-sim.mjs, not source. Ignored by name
|
||||||
|
# AND by extension so a stray archive can never be committed by accident.
|
||||||
|
/wedding_sample_images.zip
|
||||||
|
*.zip
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
27
e2e/Caddyfile.sim
Normal file
27
e2e/Caddyfile.sim
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Caddyfile for the EVENT SIMULATION stack (e2e/docker-compose.sim.yml).
|
||||||
|
# Identical in behaviour to Caddyfile.test — same compression carve-out for SSE,
|
||||||
|
# same security headers, same export framing rules — but on :3102 so the
|
||||||
|
# simulation can run without colliding with the :3101 Playwright stack.
|
||||||
|
|
||||||
|
:3102 {
|
||||||
|
# Mirror prod: exclude the SSE stream from compression so buffering doesn't
|
||||||
|
# delay real-time events.
|
||||||
|
@compressible not path /api/v1/stream
|
||||||
|
encode @compressible zstd gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
@framable path /api/v1/export/zip /api/v1/export/html
|
||||||
|
@not_framable not path /api/v1/export/zip /api/v1/export/html
|
||||||
|
header @framable X-Frame-Options "SAMEORIGIN"
|
||||||
|
header @not_framable X-Frame-Options "DENY"
|
||||||
|
|
||||||
|
reverse_proxy /api/* app:3000
|
||||||
|
reverse_proxy /media/* app:3000
|
||||||
|
reverse_proxy /health app:3000
|
||||||
|
|
||||||
|
reverse_proxy frontend:3001
|
||||||
|
}
|
||||||
153
e2e/docker-compose.sim.yml
Normal file
153
e2e/docker-compose.sim.yml
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# EventSnap EVENT SIMULATION stack — models the real production box, not CI.
|
||||||
|
#
|
||||||
|
# Difference from docker-compose.test.yml (which is tuned for fast, unconstrained
|
||||||
|
# Playwright runs): this file reproduces the CX22 the event actually runs on —
|
||||||
|
# * 2 vCPU total, shared by all four services
|
||||||
|
# * 4 GB RAM, split by the same per-service limits production ships
|
||||||
|
# * a REAL 30 GB filesystem for media + exports (loopback ext4), so statvfs
|
||||||
|
# inside the container returns true numbers and the disk gate / 507 path is
|
||||||
|
# exercised for real rather than simulated.
|
||||||
|
#
|
||||||
|
# Why cpuset on every service: production's per-service `cpus` ceilings sum to
|
||||||
|
# 1.5 + 1.2 + 0.6 + 0.5 = 3.8 on a box that has 2. That oversubscription is the
|
||||||
|
# point — the ceilings only bind when something else is competing, and cpu_shares
|
||||||
|
# decides who wins. Reproducing that on a 12-core workstation requires confining
|
||||||
|
# every container to the SAME two cores; without cpuset each service would get its
|
||||||
|
# ceiling simultaneously and the contention under test would never happen.
|
||||||
|
#
|
||||||
|
# Bring up: docker compose -f docker-compose.sim.yml up -d --build
|
||||||
|
# Tear down: docker compose -f docker-compose.sim.yml down -v
|
||||||
|
#
|
||||||
|
# The 30 GB volume is created out-of-band (see e2e/loadtest/sim-disk.sh) because a
|
||||||
|
# loopback device must be attached by root; it is declared `external` here.
|
||||||
|
|
||||||
|
x-cpuset: &cpuset '0,1'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
cpuset: *cpuset
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: eventsnap_test
|
||||||
|
POSTGRES_PASSWORD: eventsnap_test
|
||||||
|
POSTGRES_DB: eventsnap_test
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U eventsnap_test -d eventsnap_test']
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
ports:
|
||||||
|
- '55433:5432'
|
||||||
|
volumes:
|
||||||
|
# Named (not anonymous) so a `down -v` really wipes it and so its on-disk size
|
||||||
|
# can be measured against the 10 GB DISK_RESERVE that is meant to cover it.
|
||||||
|
- sim_pgdata:/var/lib/postgresql/data
|
||||||
|
# Production values, verbatim (docker-compose.yml db service).
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
cpus: '1.5'
|
||||||
|
reservations:
|
||||||
|
memory: 256M
|
||||||
|
cpu_shares: 2048
|
||||||
|
memswap_limit: 1152m
|
||||||
|
|
||||||
|
app:
|
||||||
|
# The SHIPPED release image, not a local build. Verified identical to HEAD:
|
||||||
|
# `git diff v0.17.5 HEAD -- backend/` is empty, and v0.17.5/v0.17.6 share one
|
||||||
|
# app digest on purpose (the v0.17.6 bump was frontend-only). Running the real
|
||||||
|
# artifact means the simulation tests what the event will actually run.
|
||||||
|
image: ${SIM_APP_IMAGE:-registry.mc02.dev/eventsnap/app:v0.17.5}
|
||||||
|
cpuset: *cpuset
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://eventsnap_test:eventsnap_test@db:5432/eventsnap_test
|
||||||
|
JWT_SECRET: 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff
|
||||||
|
# bcrypt("admin-test-pw"), cost 4. $ doubled to escape compose interpolation.
|
||||||
|
ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32
|
||||||
|
EVENT_SLUG: sim-wedding
|
||||||
|
EVENT_NAME: Hochzeit Simulation
|
||||||
|
APP_PORT: '3000'
|
||||||
|
# Both live on the SAME 30 GB filesystem, as they do on the real VPS — but in
|
||||||
|
# sibling directories, because config.rs::validate requires EXPORT_PATH outside
|
||||||
|
# MEDIA_PATH (a keepsake archive contains every photo in the event).
|
||||||
|
MEDIA_PATH: /disk/media
|
||||||
|
EXPORT_PATH: /disk/exports
|
||||||
|
SESSION_EXPIRY_DAYS: '30'
|
||||||
|
# Production pins these; the CI stack leaves them at code defaults. Sized to 2 vCPU.
|
||||||
|
DATABASE_MAX_CONNECTIONS: '15'
|
||||||
|
COMPRESSION_WORKER_CONCURRENCY: '2'
|
||||||
|
# Production disables comments for this event (docker-compose.yml, product decision).
|
||||||
|
COMMENTS_ENABLED: 'false'
|
||||||
|
# The ONE deviation from production: enables /admin/__truncate so the harness can
|
||||||
|
# reset between runs. Never set on the real box.
|
||||||
|
EVENTSNAP_TEST_MODE: '1'
|
||||||
|
RUST_LOG: eventsnap_backend=info,tower_http=warn
|
||||||
|
volumes:
|
||||||
|
- sim_disk:/disk
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
cpus: '1.2'
|
||||||
|
reservations:
|
||||||
|
memory: 256M
|
||||||
|
cpu_shares: 512
|
||||||
|
memswap_limit: 1152m
|
||||||
|
expose:
|
||||||
|
- '3000'
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
# Shipped release image; `git diff v0.17.6 HEAD -- frontend/` is empty.
|
||||||
|
image: registry.mc02.dev/eventsnap/frontend:v0.17.6
|
||||||
|
cpuset: *cpuset
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
environment:
|
||||||
|
PORT: '3001'
|
||||||
|
HOST: '0.0.0.0'
|
||||||
|
ORIGIN: 'http://localhost:3102'
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '0.6'
|
||||||
|
cpu_shares: 256
|
||||||
|
memswap_limit: 320m
|
||||||
|
expose:
|
||||||
|
- '3001'
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
cpuset: *cpuset
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
- frontend
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile.sim:/etc/caddy/Caddyfile:ro
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '0.5'
|
||||||
|
cpu_shares: 1024
|
||||||
|
memswap_limit: 320m
|
||||||
|
ports:
|
||||||
|
- '3102:3102'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# 30 GB loopback ext4, created by e2e/loadtest/sim-disk.sh. Holds media AND exports,
|
||||||
|
# exactly as one VPS disk holds both.
|
||||||
|
sim_disk:
|
||||||
|
external: true
|
||||||
|
name: eventsnap_sim_media
|
||||||
|
# Postgres data stays on the host disk, NOT on the 30 GB volume. Scoping decision:
|
||||||
|
# the 30 GB budget under test is the one the APP manages and measures (statvfs on
|
||||||
|
# MEDIA_PATH drives the disk gate). Putting PG on the same volume would test
|
||||||
|
# filesystem exhaustion instead, and a full disk under Postgres risks ending the run
|
||||||
|
# for an infrastructural reason rather than an application one. Its growth is sampled
|
||||||
|
# separately and reported against DISK_RESERVE_BYTES (10 GB), which exists to cover it.
|
||||||
|
sim_pgdata:
|
||||||
214
e2e/loadtest/browser-check.mjs
Normal file
214
e2e/loadtest/browser-check.mjs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Real-browser pass over the event AFTER the load simulation has filled it.
|
||||||
|
*
|
||||||
|
* `event-sim.mjs` drives the HTTP API directly — deliberately, because 150 real
|
||||||
|
* browsers would bottleneck the test box rather than the server. That leaves two
|
||||||
|
* things unmeasured, and both are guest-visible:
|
||||||
|
*
|
||||||
|
* 1. The SvelteKit frontend container, which the API driver never touches.
|
||||||
|
* 2. Whether the frontend ESCAPES the XSS caption the API stored verbatim. The
|
||||||
|
* backend stores captions raw by design (`upload.rs` length-checks only), so
|
||||||
|
* the entire defence is the renderer. The abuse suite proved the payload is
|
||||||
|
* in the database; only a browser can prove it is inert.
|
||||||
|
*
|
||||||
|
* Runs three real engines against the loaded gallery and reports load timings,
|
||||||
|
* console errors, and the XSS verdict.
|
||||||
|
*
|
||||||
|
* node e2e/loadtest/browser-check.mjs
|
||||||
|
*/
|
||||||
|
import { chromium, firefox, webkit, devices } from '@playwright/test';
|
||||||
|
import { writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const BASE = process.env.SIM_BASE ?? 'http://localhost:3102';
|
||||||
|
const API = `${BASE}/api/v1`;
|
||||||
|
const OUT = join(__dirname, 'results', 'browser');
|
||||||
|
|
||||||
|
const XSS_CAPTION = '<script>alert(document.cookie)</script><img src=x onerror=alert(1)>';
|
||||||
|
const XSS_MARKERS = ['<script>alert(', 'onerror=alert(1)', 'onerror=alert('];
|
||||||
|
|
||||||
|
async function joinGuest(name) {
|
||||||
|
const res = await fetch(`${API}/join`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: name }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`join failed ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the guest session the way the app itself does, so we land on the gallery. */
|
||||||
|
async function seedSession(context, acct, name) {
|
||||||
|
await context.addInitScript(
|
||||||
|
([jwt, uid, dn]) => {
|
||||||
|
localStorage.setItem('eventsnap_jwt', jwt);
|
||||||
|
localStorage.setItem('eventsnap_user_id', uid);
|
||||||
|
localStorage.setItem('eventsnap_display_name', dn);
|
||||||
|
// Skip the first-run guide. It is a modal over the feed, and leaving it up
|
||||||
|
// means the feed never lazy-loads — which silently turns the XSS check into
|
||||||
|
// "the payload wasn't on screen", not "the payload was neutralised".
|
||||||
|
localStorage.setItem('eventsnap_guide_seen', '1');
|
||||||
|
},
|
||||||
|
[acct.jwt, acct.user_id, name]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a fresh XSS caption so the payload is the NEWEST item and therefore on the
|
||||||
|
* first page of the feed. The load test's payload is real but buried behind ~780
|
||||||
|
* newer uploads, and a check that never renders the payload proves nothing.
|
||||||
|
*/
|
||||||
|
async function seedXssUpload(jwt) {
|
||||||
|
const { readdir, readFile } = await import('node:fs/promises');
|
||||||
|
const dir = process.env.SIM_POOL_DIR ?? '/tmp/eventsnap-realpool';
|
||||||
|
const files = (await readdir(dir)).filter((f) => f.toLowerCase().endsWith('.jpg') || f.toLowerCase().endsWith('.jpeg'));
|
||||||
|
const buf = await readFile(join(dir, files[0]));
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', new Blob([buf], { type: 'image/jpeg' }), 'x.jpg');
|
||||||
|
form.append('caption', XSS_CAPTION);
|
||||||
|
const res = await fetch(`${API}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`xss seed upload failed ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||||
|
return (await res.json()).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runEngine(engineName, launcher, deviceProfile) {
|
||||||
|
const result = { engine: engineName, device: deviceProfile ?? 'desktop', pages: {}, consoleErrors: [], dialogs: [], xss: null };
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
browser = await launcher.launch();
|
||||||
|
} catch (e) {
|
||||||
|
// A missing system library on the test box is not a finding about the app.
|
||||||
|
// Skip the engine and say so, rather than failing the whole pass.
|
||||||
|
result.skipped = `could not launch: ${String(e).split('\n')[0].slice(0, 120)}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const context = await browser.newContext(deviceProfile ? devices[deviceProfile] : {});
|
||||||
|
const acct = await joinGuest(`Browser ${engineName} ${Date.now() % 10000}`);
|
||||||
|
await seedSession(context, acct, `Browser ${engineName}`);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
// An executed payload would surface as a dialog. Nothing should ever fire.
|
||||||
|
page.on('dialog', async (d) => {
|
||||||
|
result.dialogs.push({ type: d.type(), message: d.message() });
|
||||||
|
await d.dismiss();
|
||||||
|
});
|
||||||
|
page.on('console', (m) => {
|
||||||
|
if (m.type() === 'error') result.consoleErrors.push(m.text().slice(0, 200));
|
||||||
|
});
|
||||||
|
page.on('pageerror', (e) => result.consoleErrors.push(`pageerror: ${String(e).slice(0, 200)}`));
|
||||||
|
|
||||||
|
for (const [label, path] of [
|
||||||
|
['gallery', '/'],
|
||||||
|
['diashow', '/diashow'],
|
||||||
|
]) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
await page.goto(`${BASE}${path}`, { waitUntil: 'load', timeout: 60000 });
|
||||||
|
const loadMs = Date.now() - t0;
|
||||||
|
await page.waitForTimeout(6000); // let the feed + images settle
|
||||||
|
const imgCount = await page.locator('img').count();
|
||||||
|
const shot = join(OUT, `${engineName}-${label}.png`);
|
||||||
|
await page.screenshot({ path: shot, fullPage: false });
|
||||||
|
result.pages[label] = { loadMs, settledMs: Date.now() - t0, imgCount, screenshot: shot };
|
||||||
|
} catch (e) {
|
||||||
|
result.pages[label] = { error: String(e).slice(0, 200) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── XSS verdict ───────────────────────────────────────────────────────────
|
||||||
|
// Walk the gallery looking for the stored payload. It must appear as TEXT and
|
||||||
|
// never as a live <script> element or an img with an onerror handler.
|
||||||
|
try {
|
||||||
|
await seedXssUpload(acct.jwt);
|
||||||
|
await page.goto(`${BASE}/`, { waitUntil: 'load', timeout: 60000 });
|
||||||
|
await page.waitForTimeout(6000);
|
||||||
|
const verdict = await page.evaluate((markers) => {
|
||||||
|
const bodyText = document.body.innerText ?? '';
|
||||||
|
const html = document.body.innerHTML ?? '';
|
||||||
|
const asText = markers.some((m) => bodyText.includes(m));
|
||||||
|
// Any script tag that isn't a real app/module script would be injected.
|
||||||
|
const injectedScripts = [...document.querySelectorAll('script')]
|
||||||
|
.filter((s) => (s.textContent ?? '').includes('alert('))
|
||||||
|
.map((s) => (s.textContent ?? '').slice(0, 80));
|
||||||
|
const onerrorImgs = [...document.querySelectorAll('img[onerror]')].map((i) => i.getAttribute('onerror'));
|
||||||
|
return {
|
||||||
|
payloadVisibleAsText: asText,
|
||||||
|
injectedScripts,
|
||||||
|
onerrorImgs,
|
||||||
|
// escaped entities are the positive signal that the renderer did its job
|
||||||
|
escapedMarkup: html.includes('<script>') || html.includes('<img'),
|
||||||
|
};
|
||||||
|
}, XSS_MARKERS);
|
||||||
|
result.xss = verdict;
|
||||||
|
} catch (e) {
|
||||||
|
result.xss = { error: String(e).slice(0, 200) };
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
await mkdir(OUT, { recursive: true });
|
||||||
|
const results = [];
|
||||||
|
results.push(await runEngine('chromium', chromium, 'Pixel 7'));
|
||||||
|
results.push(await runEngine('webkit', webkit, 'iPhone 14'));
|
||||||
|
results.push(await runEngine('firefox', firefox, null));
|
||||||
|
|
||||||
|
const out = join(__dirname, 'results', `browser-check-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
|
||||||
|
await writeFile(out, JSON.stringify(results, null, 2));
|
||||||
|
|
||||||
|
console.log('\n' + '═'.repeat(70));
|
||||||
|
console.log('REAL BROWSER CHECK');
|
||||||
|
console.log('═'.repeat(70));
|
||||||
|
let xssFail = false;
|
||||||
|
let inconclusive = false;
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(`\n${r.engine} (${r.device})`);
|
||||||
|
if (r.skipped) {
|
||||||
|
console.log(` SKIPPED — ${r.skipped}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(r.pages))
|
||||||
|
console.log(
|
||||||
|
` ${k.padEnd(8)} ${v.error ? 'ERROR ' + v.error : `load ${v.loadMs}ms, ${v.imgCount} <img> after settle`}`
|
||||||
|
);
|
||||||
|
console.log(` console errors: ${r.consoleErrors.length}${r.consoleErrors.length ? ' → ' + r.consoleErrors[0] : ''}`);
|
||||||
|
console.log(` dialogs fired : ${r.dialogs.length}`);
|
||||||
|
const x = r.xss ?? {};
|
||||||
|
const bad = (x.injectedScripts?.length ?? 0) > 0 || (x.onerrorImgs?.length ?? 0) > 0 || r.dialogs.length > 0;
|
||||||
|
// Absence of an explosion only counts if the payload actually reached the DOM.
|
||||||
|
const rendered = !!(x.payloadVisibleAsText || x.escapedMarkup);
|
||||||
|
if (bad) xssFail = true;
|
||||||
|
if (!bad && !rendered) inconclusive = true;
|
||||||
|
const label = bad ? '✗ PAYLOAD LIVE' : rendered ? '✓ inert (rendered as text)' : '? INCONCLUSIVE — payload never reached the DOM';
|
||||||
|
console.log(
|
||||||
|
` XSS : ${label} ` +
|
||||||
|
`(as text: ${x.payloadVisibleAsText}, escaped markup: ${x.escapedMarkup}, ` +
|
||||||
|
`injected scripts: ${x.injectedScripts?.length ?? 0}, onerror imgs: ${x.onerrorImgs?.length ?? 0})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`\nverdict: ${
|
||||||
|
xssFail
|
||||||
|
? '✗ stored XSS EXECUTES in a real browser'
|
||||||
|
: inconclusive
|
||||||
|
? '? INCONCLUSIVE in at least one engine — payload never rendered, so nothing was proven'
|
||||||
|
: '✓ stored payload rendered as inert text in every engine that ran'
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
console.log(`report → ${out}`);
|
||||||
|
console.log('═'.repeat(70));
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('browser check failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
1556
e2e/loadtest/event-sim.mjs
Normal file
1556
e2e/loadtest/event-sim.mjs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user