Skip to content

Commit ca2a97d

Browse files
c4ffeinclaude
andcommitted
tests: add XSS security smoke tests
Add E2E security tests that inject malicious payloads directly via API (bypassing UI sanitization) to verify frontend XSS protection. Test coverage: - Image URL injection (onerror, onload, javascript:, data:) - Article description in feed - Article body markdown (script, img, svg, iframe, anchor, div events) Tests run separately from main E2E suite via and are scheduled weekly in CI. Ref: realworld-apps/realworld#525 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 15fadd8 commit ca2a97d

4 files changed

Lines changed: 336 additions & 10 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Security Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
security-tests:
11+
timeout-minutes: 10
12+
runs-on: ubuntu-latest
13+
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- name: Setup Bun
18+
uses: oven-sh/setup-bun@v2
19+
20+
- name: Install dependencies
21+
run: bun install
22+
23+
- name: Install Playwright Browsers
24+
run: bunx playwright install --with-deps chromium
25+
26+
- name: Run Security Tests
27+
run: bun run test:e2e:security
28+
env:
29+
CI: true
30+
31+
- name: Upload Playwright Report
32+
uses: actions/upload-artifact@v4
33+
if: always()
34+
with:
35+
name: security-test-report
36+
path: playwright-report/
37+
retention-days: 30
38+
39+
- name: Upload Test Results
40+
uses: actions/upload-artifact@v4
41+
if: failure()
42+
with:
43+
name: security-test-results
44+
path: test-results/
45+
retention-days: 30

e2e/helpers/api.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,9 @@ export async function registerUserViaAPI(request: APIRequestContext, user: UserC
1818
},
1919
},
2020
});
21-
2221
if (!response.ok()) {
2322
throw new Error(`Failed to register user: ${response.status()}`);
2423
}
25-
2624
const data = await response.json();
2725
return data.user.token;
2826
}
@@ -36,11 +34,9 @@ export async function loginUserViaAPI(request: APIRequestContext, email: string,
3634
},
3735
},
3836
});
39-
4037
if (!response.ok()) {
4138
throw new Error(`Failed to login: ${response.status()}`);
4239
}
43-
4440
const data = await response.json();
4541
return data.user.token;
4642
}
@@ -63,15 +59,31 @@ export async function createArticleViaAPI(
6359
},
6460
},
6561
});
66-
6762
if (!response.ok()) {
6863
throw new Error(`Failed to create article: ${response.status()}`);
6964
}
70-
7165
const data = await response.json();
7266
return data.article.slug;
7367
}
7468

69+
export async function updateUserViaAPI(
70+
request: APIRequestContext,
71+
token: string,
72+
updates: { image?: string; bio?: string; username?: string; email?: string },
73+
): Promise<void> {
74+
const response = await request.put(`${API_BASE}/user`, {
75+
headers: {
76+
Authorization: `Token ${token}`,
77+
},
78+
data: {
79+
user: updates,
80+
},
81+
});
82+
if (!response.ok()) {
83+
throw new Error(`Failed to update user: ${response.status()}`);
84+
}
85+
}
86+
7587
export async function createManyArticles(
7688
request: APIRequestContext,
7789
token: string,
@@ -80,7 +92,6 @@ export async function createManyArticles(
8092
): Promise<string[]> {
8193
const slugs: string[] = [];
8294
const uniqueId = `${Date.now()}${Math.random().toString(36).substring(2, 8)}`;
83-
8495
for (let i = 0; i < count; i++) {
8596
const slug = await createArticleViaAPI(request, token, {
8697
title: `Test Article ${uniqueId} Number ${i}`,
@@ -89,10 +100,8 @@ export async function createManyArticles(
89100
tagList: [tag],
90101
});
91102
slugs.push(slug);
92-
93103
// Small pause between articles to avoid rate limits
94104
await new Promise(resolve => setTimeout(resolve, 100));
95105
}
96-
97106
return slugs;
98107
}

e2e/xss-security.spec.ts

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
import { generateUniqueUser } from './helpers/auth';
3+
import { registerUserViaAPI, updateUserViaAPI, createArticleViaAPI } from './helpers/api';
4+
5+
/**
6+
* XSS Security Tests - BASIC SMOKE TESTS ONLY
7+
*
8+
* ⚠️ IMPORTANT DISCLAIMER ⚠️
9+
*
10+
* These tests are NOT a comprehensive security audit. They only check for a few
11+
* common, naive XSS attack patterns and should NOT give you a false sense of security.
12+
*
13+
* What these tests DO:
14+
* - Verify basic protection against common XSS payloads in image URLs
15+
* - Check that basic sanitization is present and working for markdown / urls
16+
* - Catch obvious security regressions
17+
*
18+
* What these tests DO NOT:
19+
* - Cover all possible XSS vectors (this a whole different scope) and bypass techniques
20+
* - Replace a proper security audit / penetration testing
21+
* - Actually guarantee the application is secure against XSS
22+
*
23+
* For production applications, you should:
24+
* - Conduct regular security audits by professionals
25+
* - Use automated security scanning tools (OWASP ZAP, Burp Suite, etc.)
26+
* - Implement Content Security Policy (CSP) headers
27+
* - Keep dependencies updated (especially sanitization libraries)
28+
* - Follow OWASP guidelines: https://owasp.org/www-community/xss-filter-evasion-cheatsheet
29+
*
30+
* IMPORTANT: These tests inject malicious payloads directly via API (POST/PUT),
31+
* bypassing any UI-level sanitization. This simulates a real attack where an
32+
* attacker uses curl/Postman to inject malicious data directly into the backend.
33+
*
34+
* Reference: https://github.com/realworld-apps/realworld/issues/525
35+
*/
36+
37+
// Common XSS payloads targeting image src attribute
38+
const XSS_IMAGE_PAYLOADS = [
39+
{
40+
name: 'onerror attribute injection',
41+
payload: 'https://example.com/img.jpg"onerror="alert(1)',
42+
},
43+
{
44+
name: 'onerror with javascript protocol',
45+
payload: 'https://example.com/img.jpg"onerror="javascript:alert(document.cookie)',
46+
},
47+
{
48+
name: 'onload attribute injection',
49+
payload: 'https://example.com/img.jpg"onload="alert(1)',
50+
},
51+
{
52+
name: 'javascript protocol in src',
53+
payload: 'javascript:alert(1)',
54+
},
55+
{
56+
name: 'data URI with script',
57+
payload: 'data:text/html,<script>alert(1)</script>',
58+
},
59+
];
60+
61+
// XSS payloads targeting markdown/HTML content (article body)
62+
const XSS_MARKDOWN_PAYLOADS = [
63+
{
64+
name: 'script tag',
65+
payload: '<script>alert(1)</script>',
66+
},
67+
{
68+
name: 'img onerror',
69+
payload: '<img src=x onerror="alert(1)">',
70+
},
71+
{
72+
name: 'svg onload',
73+
payload: '<svg onload="alert(1)">',
74+
},
75+
{
76+
name: 'iframe srcdoc',
77+
payload: '<iframe srcdoc="<script>alert(1)</script>">',
78+
},
79+
{
80+
name: 'anchor javascript href',
81+
payload: '<a href="javascript:alert(1)">click me</a>',
82+
},
83+
{
84+
name: 'event handler in div',
85+
payload: '<div onmouseover="alert(1)">hover me</div>',
86+
},
87+
];
88+
89+
/**
90+
* Sets up a listener for any dialog (alert, confirm, prompt) on the page.
91+
* Returns a function that checks if any dialog was triggered.
92+
*/
93+
function setupXssDetector(page: Page): () => boolean {
94+
let xssTriggered = false;
95+
page.on('dialog', async dialog => {
96+
xssTriggered = true;
97+
await dialog.dismiss();
98+
});
99+
return () => xssTriggered;
100+
}
101+
102+
/**
103+
* Injects the JWT token into the browser's localStorage to authenticate the session.
104+
*/
105+
async function injectToken(page: Page, token: string): Promise<void> {
106+
await page.goto('/');
107+
await page.evaluate(t => {
108+
localStorage.setItem('jwtToken', t);
109+
}, token);
110+
}
111+
112+
test.describe('@security XSS Security - Image URL Injection (Direct API)', () => {
113+
for (const { name, payload } of XSS_IMAGE_PAYLOADS) {
114+
test(`should prevent XSS via ${name}`, async ({ page, request }) => {
115+
const wasXssTriggered = setupXssDetector(page);
116+
// Register user via API
117+
const user = generateUniqueUser();
118+
const token = await registerUserViaAPI(request, user);
119+
// Inject malicious image URL directly via API (bypassing UI)
120+
await updateUserViaAPI(request, token, { image: payload });
121+
// Now visit the profile page as a "victim" viewing this profile
122+
await injectToken(page, token);
123+
await page.goto(`/profile/${user.username}`);
124+
await expect(page).toHaveURL(new RegExp(`/profile/${user.username}`));
125+
// Wait for profile to load (user-info section contains the image)
126+
await page.waitForSelector('.user-info', { timeout: 10000 });
127+
// Wait for any deferred/async XSS payloads to execute
128+
await page.waitForTimeout(1500);
129+
// Verify no XSS was triggered
130+
expect(wasXssTriggered()).toBe(false);
131+
// Verify the malicious payload is NOT in an executable context
132+
const imgElement = page.locator('.user-img');
133+
await expect(imgElement).toBeVisible();
134+
// Check that onerror/onload are not attributes on the img element
135+
const hasOnerror = await imgElement.evaluate(el => el.hasAttribute('onerror'));
136+
expect(hasOnerror).toBe(false);
137+
const hasOnload = await imgElement.evaluate(el => el.hasAttribute('onload'));
138+
expect(hasOnload).toBe(false);
139+
});
140+
}
141+
142+
test('should safely render malicious payload in navbar image', async ({ page, request }) => {
143+
const wasXssTriggered = setupXssDetector(page);
144+
// Register and inject malicious image via API
145+
const user = generateUniqueUser();
146+
const token = await registerUserViaAPI(request, user);
147+
const maliciousImage = 'https://x.com/img.jpg"onerror="alert(document.cookie)';
148+
await updateUserViaAPI(request, token, { image: maliciousImage });
149+
// Inject token and navigate around to trigger navbar re-renders
150+
await injectToken(page, token);
151+
await page.goto('/');
152+
await page.waitForTimeout(1000);
153+
await page.goto('/settings');
154+
await page.waitForTimeout(1000);
155+
// Check navbar image doesn't have event handlers
156+
const navbarImg = page.locator('nav .user-pic');
157+
if (await navbarImg.isVisible()) {
158+
const hasOnerror = await navbarImg.evaluate(el => el.hasAttribute('onerror'));
159+
expect(hasOnerror).toBe(false);
160+
}
161+
expect(wasXssTriggered()).toBe(false);
162+
});
163+
164+
test('should safely render malicious payload in article comments', async ({ page, request }) => {
165+
const wasXssTriggered = setupXssDetector(page);
166+
// Register and inject malicious image via API
167+
const user = generateUniqueUser();
168+
const token = await registerUserViaAPI(request, user);
169+
const maliciousImage = 'https://x.com/img.jpg"onerror="alert(1)';
170+
await updateUserViaAPI(request, token, { image: maliciousImage });
171+
// Inject token and go to an article
172+
await injectToken(page, token);
173+
await page.goto('/');
174+
await page.waitForSelector('.article-preview a.preview-link, .article-preview');
175+
// Click on the first article
176+
const articleLink = page.locator('.article-preview a.preview-link').first();
177+
if (await articleLink.isVisible()) {
178+
await articleLink.click();
179+
await page.waitForURL(/\/article\//);
180+
// Wait for comment section to load and any XSS to trigger
181+
await page.waitForTimeout(1500);
182+
// The user's image appears in the comment form
183+
const commentAuthorImg = page.locator('.comment-author-img').first();
184+
if (await commentAuthorImg.isVisible()) {
185+
const hasOnerror = await commentAuthorImg.evaluate(el => el.hasAttribute('onerror'));
186+
expect(hasOnerror).toBe(false);
187+
}
188+
}
189+
expect(wasXssTriggered()).toBe(false);
190+
});
191+
});
192+
193+
test.describe('@security XSS Security - Article Description in Feed (Direct API)', () => {
194+
const XSS_DESCRIPTION_PAYLOADS = [
195+
{ name: 'script tag', payload: '<script>alert(1)</script>' },
196+
{ name: 'img onerror', payload: '<img src=x onerror="alert(1)">' },
197+
{ name: 'svg onload', payload: '<svg onload="alert(1)">' },
198+
];
199+
for (const { name, payload } of XSS_DESCRIPTION_PAYLOADS) {
200+
test(`should sanitize ${name} in article description`, async ({ page, request }) => {
201+
const wasXssTriggered = setupXssDetector(page);
202+
// Register user and create article with malicious description via API
203+
const user = generateUniqueUser();
204+
const token = await registerUserViaAPI(request, user);
205+
const timestamp = Date.now();
206+
await createArticleViaAPI(request, token, {
207+
title: `XSS Desc Test ${timestamp}`,
208+
description: `Before: ${payload} After`,
209+
body: 'Normal body content',
210+
});
211+
// Inject token and visit user's profile to see their articles
212+
await injectToken(page, token);
213+
await page.goto(`/profile/${user.username}`);
214+
// Wait for article preview to render
215+
await page.waitForSelector('.article-preview', { timeout: 10000 });
216+
// Wait for any XSS to trigger
217+
await page.waitForTimeout(1500);
218+
// Verify no XSS was triggered
219+
expect(wasXssTriggered()).toBe(false);
220+
// Check the description doesn't contain executable elements
221+
const description = page.locator('.article-preview p').first();
222+
if (await description.isVisible()) {
223+
// The payload should be visible as escaped text, not executed
224+
const text = await description.textContent();
225+
expect(text).toContain('Before:');
226+
// Verify no script tags were injected into the DOM
227+
const scriptCount = await page.locator('.article-preview script').count();
228+
expect(scriptCount).toBe(0);
229+
}
230+
});
231+
}
232+
});
233+
234+
test.describe('@security XSS Security - Article Body Markdown (Direct API)', () => {
235+
for (const { name, payload } of XSS_MARKDOWN_PAYLOADS) {
236+
test(`should sanitize ${name} in article body`, async ({ page, request }) => {
237+
const wasXssTriggered = setupXssDetector(page);
238+
// Register user and create article with malicious body via API
239+
const user = generateUniqueUser();
240+
const token = await registerUserViaAPI(request, user);
241+
const timestamp = Date.now();
242+
const slug = await createArticleViaAPI(request, token, {
243+
title: `XSS Test ${timestamp}`,
244+
description: 'Testing XSS protection',
245+
body: `Before payload: ${payload} After payload`,
246+
});
247+
// Inject token FIRST (session isolation - must be same user to view article)
248+
await injectToken(page, token);
249+
// Now visit the article page as the authenticated user
250+
await page.goto(`/article/${slug}`);
251+
// Wait for content to render
252+
await page.waitForSelector('.article-content', { timeout: 10000 });
253+
// Wait for any XSS to trigger
254+
await page.waitForTimeout(1500);
255+
// Verify no XSS was triggered
256+
expect(wasXssTriggered()).toBe(false);
257+
// Check the article body container for dangerous elements/attributes
258+
const articleBody = page.locator('.article-content');
259+
await expect(articleBody).toBeVisible();
260+
// Verify no script tags exist
261+
const scriptCount = await articleBody.locator('script').count();
262+
expect(scriptCount).toBe(0);
263+
// Verify no elements with dangerous event handlers
264+
const dangerousHandlers = ['onerror', 'onload', 'onmouseover', 'onclick', 'onfocus'];
265+
for (const handler of dangerousHandlers) {
266+
const elementsWithHandler = await articleBody.locator(`[${handler}]`).count();
267+
expect(elementsWithHandler).toBe(0);
268+
}
269+
});
270+
}
271+
});

0 commit comments

Comments
 (0)