/**
* 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('