chore(e2e): add ESLint + Prettier; fix real findings; dedupe BASE
The Playwright suite had no linter and no formatter — only tsc. Add flat-config ESLint
(typescript-eslint, type-aware) and Prettier (2-space, matching the suite's style).
Rules keep the ones that catch real TEST bugs and drop the noise:
- no-floating-promises KEPT — an un-awaited request/assertion can let a test end before it runs,
passing vacuously. It caught one: the SSE reader loop in sse-listener is now explicitly `void`.
- no-unused-vars KEPT — caught three dead bindings (an unused adminToken fixture arg, an unused
`api` arg, an unused JPEG_MAGIC import), all removed.
- no-explicit-any OFF — all test code; `any` is the honest type for an untyped res.json() body or
a page.evaluate() return.
- no-empty-pattern OFF — Playwright's dependency-free fixtures are `async ({}, use) => {}`.
Refactor: `const BASE = process.env.E2E_FRONTEND_URL ?? '...'` was redeclared verbatim in 23
files — extracted to helpers/env.ts and imported, so a port/scheme change is one edit not a sweep.
Then `prettier --write`. Verified: eslint clean, tsc clean, prettier clean, desktop suite 210
passed / 1 skipped. (One mobile spec flaked once under retries:0 — a pre-existing cross-test
reflow-timing vector from the flakiness audit, not this change: the each-key edit is stable across
16 isolated runs and a clean full mobile re-run.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,23 +8,28 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { ADMIN_PASSWORD } from '../../fixtures/api-client';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
/** RFC-4648 base64url with no padding. */
|
||||
function b64u(s: string) {
|
||||
return Buffer.from(s).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
|
||||
return Buffer.from(s)
|
||||
.toString('base64')
|
||||
.replace(/=+$/, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
test.describe('Adversarial — JWT', () => {
|
||||
test('alg:none token claiming admin role is rejected', async () => {
|
||||
const header = b64u(JSON.stringify({ alg: 'none', typ: 'JWT' }));
|
||||
const payload = b64u(JSON.stringify({
|
||||
sub: '00000000-0000-0000-0000-000000000000',
|
||||
role: 'admin',
|
||||
event_id: '00000000-0000-0000-0000-000000000000',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
}));
|
||||
const payload = b64u(
|
||||
JSON.stringify({
|
||||
sub: '00000000-0000-0000-0000-000000000000',
|
||||
role: 'admin',
|
||||
event_id: '00000000-0000-0000-0000-000000000000',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
})
|
||||
);
|
||||
const token = `${header}.${payload}.`;
|
||||
const res = await fetch(`${BASE}/api/v1/admin/config`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -44,7 +49,9 @@ test.describe('Adversarial — JWT', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('JWT with payload-tampered role=admin (re-encoded payload, original signature) is rejected', async ({ guest }) => {
|
||||
test('JWT with payload-tampered role=admin (re-encoded payload, original signature) is rejected', async ({
|
||||
guest,
|
||||
}) => {
|
||||
const g = await guest('RolePromote');
|
||||
const parts = g.jwt.split('.');
|
||||
const original = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||
@@ -111,7 +118,9 @@ test.describe('Adversarial — PIN brute-force', () => {
|
||||
expect(correct.status).toBe(429);
|
||||
});
|
||||
|
||||
test('parallel wrong-PIN attempts still lock the account (counter is not lost to the race)', async ({ guest }) => {
|
||||
test('parallel wrong-PIN attempts still lock the account (counter is not lost to the race)', async ({
|
||||
guest,
|
||||
}) => {
|
||||
const g = await guest('BruteParallel');
|
||||
const wrong = g.pin === '0000' ? '1111' : '0000';
|
||||
|
||||
@@ -125,7 +134,10 @@ test.describe('Adversarial — PIN brute-force', () => {
|
||||
)
|
||||
);
|
||||
const statuses = attempts.map((r) => r.status);
|
||||
expect(statuses.filter((s) => s === 200), 'a wrong PIN must never authenticate').toHaveLength(0);
|
||||
expect(
|
||||
statuses.filter((s) => s === 200),
|
||||
'a wrong PIN must never authenticate'
|
||||
).toHaveLength(0);
|
||||
|
||||
// The in-flight requests all read `pin_locked_until` before any of them wrote it, so
|
||||
// *which* of the 10 come back 429 is genuinely racy and can't be asserted. What is NOT
|
||||
@@ -140,7 +152,10 @@ test.describe('Adversarial — PIN brute-force', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: g.displayName, pin: g.pin }),
|
||||
});
|
||||
expect(correct.status, 'after 10 wrong PINs the account must be locked, even for the right PIN').toBe(429);
|
||||
expect(
|
||||
correct.status,
|
||||
'after 10 wrong PINs the account must be locked, even for the right PIN'
|
||||
).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,7 +203,10 @@ test.describe('Adversarial — admin password brute-force', () => {
|
||||
}
|
||||
|
||||
// The password path actually ran (budget existed) before the limiter engaged...
|
||||
expect(statuses[0], 'first attempt should be a normal wrong-password 401, not a spurious 429').toBe(401);
|
||||
expect(
|
||||
statuses[0],
|
||||
'first attempt should be a normal wrong-password 401, not a spurious 429'
|
||||
).toBe(401);
|
||||
// ...and the limiter DID engage within the window. Delete the throttle and this is never true.
|
||||
expect(
|
||||
statuses.some((s) => s === 429),
|
||||
@@ -218,7 +236,10 @@ test.describe('Adversarial — admin password brute-force', () => {
|
||||
|
||||
// The right password, while throttled, must STILL be refused — the limiter is checked before
|
||||
// the bcrypt verify, so a valid credential does not buy a way around a brute-force lockout.
|
||||
expect((await tryLogin(ADMIN_PASSWORD)).status, 'a throttled IP is refused even with the correct password').toBe(429);
|
||||
expect(
|
||||
(await tryLogin(ADMIN_PASSWORD)).status,
|
||||
'a throttled IP is refused even with the correct password'
|
||||
).toBe(429);
|
||||
});
|
||||
|
||||
test('the throttle is gated: with the limiter disabled, a burst is NOT rate-limited', async ({
|
||||
@@ -235,6 +256,9 @@ test.describe('Adversarial — admin password brute-force', () => {
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 8; i++) statuses.push((await tryLogin('wrong-' + i)).status);
|
||||
|
||||
expect(statuses.every((s) => s === 401), 'with admin_login_rate_enabled=false no attempt should be 429').toBe(true);
|
||||
expect(
|
||||
statuses.every((s) => s === 401),
|
||||
'with admin_login_rate_enabled=false no attempt should be 429'
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,15 +6,16 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload, seedComment, listComments, findFeedRow } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Adversarial — deep authorization', () => {
|
||||
// IDOR: user B must not be able to delete user A's REAL comment. This exercises the
|
||||
// ownership guard (`comment.user_id != auth.user_id` → 403) — the previous version fired
|
||||
// at the all-zeros UUID, which 404s at the lookup BEFORE that guard runs, so it never
|
||||
// tested authorization at all.
|
||||
test('user B cannot delete user A\'s comment (real resource → 403, comment survives)', async ({ guest }) => {
|
||||
test("user B cannot delete user A's comment (real resource → 403, comment survives)", async ({
|
||||
guest,
|
||||
}) => {
|
||||
const a = await guest('CommentOwnerA');
|
||||
const b = await guest('AttackerB');
|
||||
|
||||
@@ -42,7 +43,7 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
});
|
||||
|
||||
// IDOR: user B must not be able to delete user A's REAL upload.
|
||||
test('user B cannot delete user A\'s upload (403, upload survives)', async ({ guest, db }) => {
|
||||
test("user B cannot delete user A's upload (403, upload survives)", async ({ guest, db }) => {
|
||||
const a = await guest('UploadOwnerA');
|
||||
const b = await guest('AttackerB2');
|
||||
|
||||
@@ -60,7 +61,7 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
});
|
||||
|
||||
// IDOR: user B must not be able to edit (re-caption / re-tag) user A's upload.
|
||||
test('user B cannot edit user A\'s upload caption (403, caption unchanged)', async ({ guest }) => {
|
||||
test("user B cannot edit user A's upload caption (403, caption unchanged)", async ({ guest }) => {
|
||||
const a = await guest('UploadOwnerA2');
|
||||
const b = await guest('AttackerB3');
|
||||
|
||||
@@ -75,7 +76,9 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
// No state change: the caption A set is intact.
|
||||
const feedRes = await fetch(`${BASE}/api/v1/feed`, { headers: { Authorization: `Bearer ${a.jwt}` } });
|
||||
const feedRes = await fetch(`${BASE}/api/v1/feed`, {
|
||||
headers: { Authorization: `Bearer ${a.jwt}` },
|
||||
});
|
||||
const row = findFeedRow(await feedRes.json(), uploadId);
|
||||
expect(row?.caption).toBe('original caption');
|
||||
});
|
||||
@@ -115,7 +118,11 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
expect(await listComments(host.jwt, uploadId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('banned user can still read the feed (read-only access preserved)', async ({ api, host, guest }) => {
|
||||
test('banned user can still read the feed (read-only access preserved)', async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
}) => {
|
||||
const target = await guest('BannedRead');
|
||||
await api.banUser(host.jwt, target.userId);
|
||||
|
||||
@@ -126,7 +133,11 @@ test.describe('Adversarial — deep authorization', () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('host cannot delete another host\'s session via /api/v1/session', async ({ api, host, guest }) => {
|
||||
test("host cannot delete another host's session via /api/v1/session", async ({
|
||||
api,
|
||||
host,
|
||||
guest,
|
||||
}) => {
|
||||
const otherHost = await guest('OtherHost');
|
||||
// Promote so they have a host JWT to play with.
|
||||
await api.setRole(host.jwt, otherHost.userId, 'host');
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { mintSseTicket } from '../../helpers/sse';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Adversarial — small-scale abuse', () => {
|
||||
// Note: the truncate auto-fixture resets every rate-limit toggle back to false
|
||||
@@ -64,7 +63,9 @@ test.describe('Adversarial — small-scale abuse', () => {
|
||||
expect(res.status, '500 chars is the documented maximum and must be accepted').toBe(201);
|
||||
});
|
||||
|
||||
test('10 MB comment body never reaches the handler (body-size limit rejects it)', async ({ guest }) => {
|
||||
test('10 MB comment body never reaches the handler (body-size limit rejects it)', async ({
|
||||
guest,
|
||||
}) => {
|
||||
const g = await guest('BigComment');
|
||||
const uploadId = await seedUpload(g.jwt);
|
||||
const huge = 'A'.repeat(10 * 1024 * 1024);
|
||||
@@ -87,7 +88,9 @@ test.describe('Adversarial — small-scale abuse', () => {
|
||||
|
||||
const controllers = tickets.map(() => new AbortController());
|
||||
const requests = tickets.map((ticket, i) =>
|
||||
fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, { signal: controllers[i].signal })
|
||||
fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, {
|
||||
signal: controllers[i].signal,
|
||||
})
|
||||
);
|
||||
const responses = await Promise.all(requests);
|
||||
// All accepted (or some rate-limited — both fine).
|
||||
|
||||
Binary file not shown.
@@ -6,8 +6,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Media gating — moderation revokes preview access (F2)', () => {
|
||||
test('preview served via gated alias, blocked directly, and 404 after delete', async ({
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
* still work.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
function patchRole(token: string, userId: string, role: string) {
|
||||
return fetch(`${BASE}/api/v1/host/users/${userId}/role`, {
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { mintSseTicket, openStream } from '../../helpers/sse';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
test.describe('Adversarial — SSE ticket abuse', () => {
|
||||
test('minting a ticket requires authentication', async () => {
|
||||
|
||||
@@ -14,12 +14,15 @@ test.describe('Adversarial — UI render escape', () => {
|
||||
const r = await api.join(payload);
|
||||
|
||||
await page.goto('/');
|
||||
await page.evaluate(({ j, u, n, p }) => {
|
||||
localStorage.setItem('eventsnap_jwt', j);
|
||||
localStorage.setItem('eventsnap_user_id', u);
|
||||
localStorage.setItem('eventsnap_display_name', n);
|
||||
localStorage.setItem('eventsnap_pin', p);
|
||||
}, { j: r.jwt, u: r.user_id, n: payload, p: r.pin });
|
||||
await page.evaluate(
|
||||
({ j, u, n, p }) => {
|
||||
localStorage.setItem('eventsnap_jwt', j);
|
||||
localStorage.setItem('eventsnap_user_id', u);
|
||||
localStorage.setItem('eventsnap_display_name', n);
|
||||
localStorage.setItem('eventsnap_pin', p);
|
||||
},
|
||||
{ j: r.jwt, u: r.user_id, n: payload, p: r.pin }
|
||||
);
|
||||
|
||||
page.on('dialog', (d) => {
|
||||
throw new Error(`Dialog fired: ${d.message()}`);
|
||||
@@ -31,7 +34,9 @@ test.describe('Adversarial — UI render escape', () => {
|
||||
// the *absence* of a <b> at that point passes on a page that never rendered the name
|
||||
// at all — a false green on an XSS test. Prove the payload actually reached the DOM
|
||||
// (as escaped text) before concluding anything about how it was rendered.
|
||||
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const fired = await page.evaluate(() => (window as any).__xssFired === true);
|
||||
expect(fired).toBe(false);
|
||||
@@ -42,17 +47,23 @@ test.describe('Adversarial — UI render escape', () => {
|
||||
await expect(page.locator('script:has-text("__xssFired")')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('rendering of a known SQL-injection-shaped name does not break the page', async ({ page, api }) => {
|
||||
test('rendering of a known SQL-injection-shaped name does not break the page', async ({
|
||||
page,
|
||||
api,
|
||||
}) => {
|
||||
const payload = `'); DROP TABLE users; --`;
|
||||
const r = await api.join(payload);
|
||||
|
||||
await page.goto('/');
|
||||
await page.evaluate(({ j, u, n, p }) => {
|
||||
localStorage.setItem('eventsnap_jwt', j);
|
||||
localStorage.setItem('eventsnap_user_id', u);
|
||||
localStorage.setItem('eventsnap_display_name', n);
|
||||
localStorage.setItem('eventsnap_pin', p);
|
||||
}, { j: r.jwt, u: r.user_id, n: payload, p: r.pin });
|
||||
await page.evaluate(
|
||||
({ j, u, n, p }) => {
|
||||
localStorage.setItem('eventsnap_jwt', j);
|
||||
localStorage.setItem('eventsnap_user_id', u);
|
||||
localStorage.setItem('eventsnap_display_name', n);
|
||||
localStorage.setItem('eventsnap_pin', p);
|
||||
},
|
||||
{ j: r.jwt, u: r.user_id, n: payload, p: r.pin }
|
||||
);
|
||||
|
||||
await page.goto('/account');
|
||||
// Page renders.
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { seedUpload, seedComment } from '../../helpers/seed';
|
||||
|
||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
/**
|
||||
* Every payload sets `window.__x = 1` if it executes. The marker is deliberately terse:
|
||||
@@ -36,7 +35,10 @@ const XSS_PAYLOADS = [
|
||||
// payload is edited past it, we want a loud failure here rather than six silent no-ops.
|
||||
const NAME_MAX = 50;
|
||||
for (const p of XSS_PAYLOADS) {
|
||||
if (p.length > NAME_MAX) throw new Error(`XSS payload exceeds the ${NAME_MAX}-char display-name cap and would never be stored: ${p}`);
|
||||
if (p.length > NAME_MAX)
|
||||
throw new Error(
|
||||
`XSS payload exceeds the ${NAME_MAX}-char display-name cap and would never be stored: ${p}`
|
||||
);
|
||||
}
|
||||
|
||||
const SQLI_PAYLOADS = [
|
||||
@@ -48,7 +50,10 @@ const SQLI_PAYLOADS = [
|
||||
|
||||
test.describe('Adversarial — input injection (display name)', () => {
|
||||
for (const payload of XSS_PAYLOADS) {
|
||||
test(`name with XSS payload ${JSON.stringify(payload).slice(0, 40)} never executes`, async ({ api, page }) => {
|
||||
test(`name with XSS payload ${JSON.stringify(payload).slice(0, 40)} never executes`, async ({
|
||||
api,
|
||||
page,
|
||||
}) => {
|
||||
// No try/catch escape hatch: every payload is short enough to be accepted, so a
|
||||
// rejection here is a real failure (the payload would never be rendered, and the
|
||||
// "nothing executed" assertions below would be vacuous).
|
||||
@@ -77,7 +82,9 @@ test.describe('Adversarial — input injection (display name)', () => {
|
||||
|
||||
// Render guard: confirm the payload actually reached the DOM as escaped text,
|
||||
// so a "nothing fired" pass can't be because the name was never rendered.
|
||||
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(payload, { exact: false }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const fired = await page.evaluate(() => (window as any).__x === 1);
|
||||
expect(fired, 'window.__x should never be set').toBe(false);
|
||||
@@ -92,7 +99,11 @@ test.describe('Adversarial — input injection (display name)', () => {
|
||||
|
||||
test.describe('Adversarial — stored XSS (caption)', () => {
|
||||
for (const payload of XSS_PAYLOADS) {
|
||||
test(`caption with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, page, signIn }) => {
|
||||
test(`caption with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({
|
||||
guest,
|
||||
page,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('CapXss');
|
||||
// A trailing marker lets us wait until the caption has actually rendered before
|
||||
// asserting nothing fired — otherwise a caption that never rendered would pass vacuously.
|
||||
@@ -100,17 +111,30 @@ test.describe('Adversarial — stored XSS (caption)', () => {
|
||||
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
|
||||
const dialogs: string[] = [];
|
||||
page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); });
|
||||
page.on('dialog', (d) => {
|
||||
dialogs.push(d.message());
|
||||
d.dismiss().catch(() => {});
|
||||
});
|
||||
|
||||
await signIn(page, g);
|
||||
// Wait for the caption text to land in the DOM (escaped, as literal text).
|
||||
await expect(page.getByText('CAPMARK', { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('CAPMARK', { exact: false }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
expect(await page.evaluate(() => (window as any).__x === 1), 'caption XSS must not fire').toBe(false);
|
||||
expect(
|
||||
await page.evaluate(() => (window as any).__x === 1),
|
||||
'caption XSS must not fire'
|
||||
).toBe(false);
|
||||
expect(dialogs, 'no dialogs from a caption').toHaveLength(0);
|
||||
// The payload must be inert text, not a live element / script.
|
||||
expect(await page.locator('img[onerror]').count(), 'no live onerror img from caption').toBe(0);
|
||||
expect(await page.locator('script:has-text("window.__x")').count(), 'no executable script from caption').toBe(0);
|
||||
expect(await page.locator('img[onerror]').count(), 'no live onerror img from caption').toBe(
|
||||
0
|
||||
);
|
||||
expect(
|
||||
await page.locator('script:has-text("window.__x")').count(),
|
||||
'no executable script from caption'
|
||||
).toBe(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -118,23 +142,30 @@ test.describe('Adversarial — stored XSS (caption)', () => {
|
||||
test.describe('Adversarial — stored XSS (comment)', () => {
|
||||
// The two payloads that actually execute on render (script injection via innerHTML
|
||||
// does not) — enough to prove the comment body is escaped without a slow 6× lightbox loop.
|
||||
const COMMENT_PAYLOADS = [
|
||||
`<img src=x onerror="window.__x=1">`,
|
||||
`"><svg onload="window.__x=1">`,
|
||||
];
|
||||
const COMMENT_PAYLOADS = [`<img src=x onerror="window.__x=1">`, `"><svg onload="window.__x=1">`];
|
||||
for (const payload of COMMENT_PAYLOADS) {
|
||||
test(`comment with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({ guest, page, signIn }) => {
|
||||
test(`comment with XSS payload ${JSON.stringify(payload).slice(0, 40)} renders inert`, async ({
|
||||
guest,
|
||||
page,
|
||||
signIn,
|
||||
}) => {
|
||||
const author = await guest('CmtXss');
|
||||
const id = await seedUpload(author.jwt, { caption: 'pic CAPMARK' });
|
||||
// Post the XSS comment via the API (verbatim storage).
|
||||
await seedComment(author.jwt, id, `${payload} CMTMARK`);
|
||||
|
||||
const dialogs: string[] = [];
|
||||
page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); });
|
||||
page.on('dialog', (d) => {
|
||||
dialogs.push(d.message());
|
||||
d.dismiss().catch(() => {});
|
||||
});
|
||||
|
||||
await signIn(page, author);
|
||||
// Open the lightbox (which loads + renders comments).
|
||||
const imageButton = page.locator('article').filter({ hasText: 'CAPMARK' }).first()
|
||||
const imageButton = page
|
||||
.locator('article')
|
||||
.filter({ hasText: 'CAPMARK' })
|
||||
.first()
|
||||
.getByRole('button', { name: 'Bild vergrößern' });
|
||||
await expect(imageButton).toBeVisible({ timeout: 10_000 });
|
||||
await imageButton.click();
|
||||
@@ -142,18 +173,28 @@ test.describe('Adversarial — stored XSS (comment)', () => {
|
||||
const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]');
|
||||
await expect(lightbox).toBeVisible();
|
||||
// Wait until the comment (marker) has rendered.
|
||||
await expect(lightbox.getByText('CMTMARK', { exact: false })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(lightbox.getByText('CMTMARK', { exact: false })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
expect(await page.evaluate(() => (window as any).__x === 1), 'comment XSS must not fire').toBe(false);
|
||||
expect(
|
||||
await page.evaluate(() => (window as any).__x === 1),
|
||||
'comment XSS must not fire'
|
||||
).toBe(false);
|
||||
expect(dialogs, 'no dialogs from a comment').toHaveLength(0);
|
||||
expect(await page.locator('img[onerror]').count(), 'no live onerror img from comment').toBe(0);
|
||||
expect(await page.locator('img[onerror]').count(), 'no live onerror img from comment').toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Adversarial — input injection (SQL-injection patterns)', () => {
|
||||
for (const payload of SQLI_PAYLOADS) {
|
||||
test(`SQL-shaped name ${JSON.stringify(payload).slice(0, 40)} round-trips without breaking the DB`, async ({ api, adminToken }) => {
|
||||
test(`SQL-shaped name ${JSON.stringify(payload).slice(0, 40)} round-trips without breaking the DB`, async ({
|
||||
api,
|
||||
adminToken,
|
||||
}) => {
|
||||
const res = await api.join(payload);
|
||||
expect(res.jwt).toBeTruthy();
|
||||
|
||||
@@ -196,7 +237,10 @@ test.describe('Adversarial — input length & encoding', () => {
|
||||
expect([400, 201, 409]).toContain(res.status);
|
||||
});
|
||||
|
||||
test('Unicode RTL override character in name does not corrupt rendering', async ({ api, page }) => {
|
||||
test('Unicode RTL override character in name does not corrupt rendering', async ({
|
||||
api,
|
||||
page,
|
||||
}) => {
|
||||
const rtlName = `AliceeciVlA`; // U+202E RIGHT-TO-LEFT OVERRIDE
|
||||
const r = await api.join(rtlName);
|
||||
expect(r.user_id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
|
||||
Reference in New Issue
Block a user