/** * LiveComponents Security E2E Tests * * Tests security features and protections: * - CSRF token validation for state-changing actions * - Rate limiting for action calls * - Idempotency for critical operations * - Input sanitization and XSS prevention * - Authorization checks for protected actions * - Session security and token rotation * * Run with: npx playwright test security.spec.js */ import { test, expect } from '@playwright/test'; test.describe('LiveComponents Security Tests', () => { test.beforeEach(async ({ page }) => { await page.goto('https://localhost/livecomponents/test/security'); await page.waitForFunction(() => window.LiveComponents !== undefined); }); test.describe('CSRF Protection', () => { test('should include CSRF token in action requests', async ({ page }) => { // Monitor network requests const actionRequests = []; page.on('request', request => { if (request.url().includes('/live-component/')) { actionRequests.push({ url: request.url(), headers: request.headers(), postData: request.postData() }); } }); // Trigger action await page.click('button#trigger-action'); // Wait for request to complete await page.waitForTimeout(1000); // Verify at least one action request was made expect(actionRequests.length).toBeGreaterThan(0); // Verify CSRF token present in request const actionRequest = actionRequests[0]; // Check for CSRF token in headers or POST data const hasCsrfHeader = 'x-csrf-token' in actionRequest.headers || 'x-xsrf-token' in actionRequest.headers; const hasCsrfInBody = actionRequest.postData && actionRequest.postData.includes('_csrf_token'); expect(hasCsrfHeader || hasCsrfInBody).toBe(true); }); test('should reject action without CSRF token', async ({ page }) => { // Intercept and remove CSRF token await page.route('**/live-component/**', async (route) => { const request = route.request(); // Remove CSRF token from headers const headers = { ...request.headers() }; delete headers['x-csrf-token']; delete headers['x-xsrf-token']; // Remove from POST data if present let postData = request.postData(); if (postData) { postData = postData.replace(/_csrf_token=[^&]+&?/, ''); } await route.continue({ headers, postData }); }); // Trigger action await page.click('button#trigger-action'); // Wait for error await page.waitForTimeout(1000); // Verify error response const errorMessage = await page.locator('.error-message').textContent(); expect(errorMessage).toContain('CSRF'); }); test('should reject action with invalid CSRF token', async ({ page }) => { // Intercept and replace CSRF token with invalid one await page.route('**/live-component/**', async (route) => { const request = route.request(); const headers = { ...request.headers(), 'x-csrf-token': 'invalid-token-12345' }; await route.continue({ headers }); }); // Trigger action await page.click('button#trigger-action'); // Wait for error await page.waitForTimeout(1000); // Verify error response const errorMessage = await page.locator('.error-message').textContent(); expect(errorMessage).toContain('CSRF'); }); test('should rotate CSRF token after usage', async ({ page }) => { // Get initial CSRF token const initialToken = await page.evaluate(() => { return document.querySelector('meta[name="csrf-token"]')?.content; }); expect(initialToken).toBeTruthy(); // Trigger action await page.click('button#trigger-action'); // Wait for action to complete await page.waitForTimeout(1000); // Get new CSRF token const newToken = await page.evaluate(() => { return document.querySelector('meta[name="csrf-token"]')?.content; }); // Tokens should be different after action // (depending on framework configuration) // For now, just verify new token exists expect(newToken).toBeTruthy(); }); }); test.describe('Rate Limiting', () => { test('should enforce rate limit on rapid action calls', async ({ page }) => { // Trigger action rapidly 20 times const results = []; for (let i = 0; i < 20; i++) { try { await page.click('button#trigger-action'); await page.waitForTimeout(50); // Small delay const success = await page.locator('.action-success').isVisible(); results.push({ success, attempt: i + 1 }); } catch (error) { results.push({ success: false, attempt: i + 1, error: error.message }); } } // Some requests should be rate limited const successCount = results.filter(r => r.success).length; // Expect rate limiting to kick in (not all 20 succeed) expect(successCount).toBeLessThan(20); // Verify rate limit error message const rateLimitError = await page.locator('.rate-limit-error').isVisible(); expect(rateLimitError).toBe(true); }); test('should provide retry-after information when rate limited', async ({ page }) => { // Trigger rapid requests to hit rate limit for (let i = 0; i < 15; i++) { await page.click('button#trigger-action'); await page.waitForTimeout(20); } // Wait for rate limit error await page.waitForSelector('.rate-limit-error', { timeout: 5000 }); // Verify retry-after information const retryAfter = await page.locator('[data-retry-after]').getAttribute('data-retry-after'); expect(retryAfter).toBeTruthy(); expect(parseInt(retryAfter)).toBeGreaterThan(0); console.log(`Rate limit retry-after: ${retryAfter} seconds`); }); test('should reset rate limit after cooldown period', async ({ page }) => { // Hit rate limit for (let i = 0; i < 15; i++) { await page.click('button#trigger-action'); await page.waitForTimeout(20); } // Verify rate limited await expect(page.locator('.rate-limit-error')).toBeVisible(); // Get retry-after duration const retryAfter = await page.locator('[data-retry-after]').getAttribute('data-retry-after'); const waitTime = parseInt(retryAfter) * 1000 + 1000; // Add 1 second buffer console.log(`Waiting ${waitTime}ms for rate limit reset...`); // Wait for cooldown await page.waitForTimeout(waitTime); // Try action again await page.click('button#trigger-action'); // Should succeed now await expect(page.locator('.action-success')).toBeVisible(); }); test('should have separate rate limits per action type', async ({ page }) => { // Hit rate limit on action A for (let i = 0; i < 15; i++) { await page.click('button#action-a'); await page.waitForTimeout(20); } // Verify action A is rate limited await expect(page.locator('.rate-limit-error-a')).toBeVisible(); // Action B should still work await page.click('button#action-b'); await expect(page.locator('.action-success-b')).toBeVisible(); }); test('should apply IP-based rate limiting', async ({ page, context }) => { // Create multiple browser contexts (simulate multiple users from same IP) const context2 = await page.context().browser().newContext(); const page2 = await context2.newPage(); await page2.goto('https://localhost/livecomponents/test/security'); await page2.waitForFunction(() => window.LiveComponents !== undefined); // User 1: Trigger many requests for (let i = 0; i < 10; i++) { await page.click('button#trigger-action'); await page.waitForTimeout(20); } // User 2: Should also be affected by IP-based limit for (let i = 0; i < 10; i++) { await page2.click('button#trigger-action'); await page2.waitForTimeout(20); } // Both should eventually be rate limited (shared IP limit) const user1Limited = await page.locator('.rate-limit-error').isVisible(); const user2Limited = await page2.locator('.rate-limit-error').isVisible(); expect(user1Limited || user2Limited).toBe(true); await context2.close(); }); }); test.describe('Idempotency', () => { test('should handle duplicate action calls idempotently', async ({ page }) => { // Get initial state const initialValue = await page.locator('#counter-value').textContent(); const initialCount = parseInt(initialValue); // Trigger increment action twice with same idempotency key const idempotencyKey = 'test-key-12345'; await page.evaluate((key) => { window.LiveComponents.get('counter:test').call('increment', {}, { idempotencyKey: key }); }, idempotencyKey); await page.waitForTimeout(500); // Same idempotency key - should be ignored await page.evaluate((key) => { window.LiveComponents.get('counter:test').call('increment', {}, { idempotencyKey: key }); }, idempotencyKey); await page.waitForTimeout(500); // Value should only increment once const finalValue = await page.locator('#counter-value').textContent(); const finalCount = parseInt(finalValue); expect(finalCount).toBe(initialCount + 1); }); test('should allow different idempotency keys', async ({ page }) => { const initialValue = await page.locator('#counter-value').textContent(); const initialCount = parseInt(initialValue); // First action with idempotency key 1 await page.evaluate(() => { window.LiveComponents.get('counter:test').call('increment', {}, { idempotencyKey: 'key-1' }); }); await page.waitForTimeout(500); // Second action with idempotency key 2 (different key) await page.evaluate(() => { window.LiveComponents.get('counter:test').call('increment', {}, { idempotencyKey: 'key-2' }); }); await page.waitForTimeout(500); // Both should execute (different keys) const finalValue = await page.locator('#counter-value').textContent(); const finalCount = parseInt(finalValue); expect(finalCount).toBe(initialCount + 2); }); test('should expire idempotency keys after TTL', async ({ page }) => { const idempotencyKey = 'expiring-key'; // First action await page.evaluate((key) => { window.LiveComponents.get('counter:test').call('increment', {}, { idempotencyKey: key }); }, idempotencyKey); await page.waitForTimeout(500); const afterFirstValue = await page.locator('#counter-value').textContent(); const afterFirstCount = parseInt(afterFirstValue); // Wait for idempotency key to expire (assume 5 second TTL) await page.waitForTimeout(6000); // Same key should work again after expiration await page.evaluate((key) => { window.LiveComponents.get('counter:test').call('increment', {}, { idempotencyKey: key }); }, idempotencyKey); await page.waitForTimeout(500); const finalValue = await page.locator('#counter-value').textContent(); const finalCount = parseInt(finalValue); expect(finalCount).toBe(afterFirstCount + 1); }); test('should return cached result for duplicate idempotent action', async ({ page }) => { const idempotencyKey = 'cached-result-key'; // First action const result1 = await page.evaluate((key) => { return window.LiveComponents.get('counter:test').call('increment', {}, { idempotencyKey: key }); }, idempotencyKey); await page.waitForTimeout(500); // Duplicate action (same key) const result2 = await page.evaluate((key) => { return window.LiveComponents.get('counter:test').call('increment', {}, { idempotencyKey: key }); }, idempotencyKey); // Results should be identical (cached) expect(result2).toEqual(result1); }); }); test.describe('Input Sanitization & XSS Prevention', () => { test('should sanitize HTML in action parameters', async ({ page }) => { // Attempt XSS injection via action parameter const xssPayload = ''; await page.evaluate((payload) => { window.LiveComponents.get('text:test').call('setText', { text: payload }); }, xssPayload); await page.waitForTimeout(500); // Verify script tag is sanitized/escaped const displayedText = await page.locator('#text-display').textContent(); // Should display as text, not execute expect(displayedText).toContain('script'); expect(displayedText).not.toContain('