|
| 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