- Add XSS protection with DOMPurify sanitization in rich text editor - Implement comprehensive file upload security validation - Enhance server-side content sanitization with wp_kses - Add comprehensive security test suite with 194+ test cases - Create security remediation plan documentation Security fixes address: - CRITICAL: XSS vulnerability in event description editor - HIGH: File upload security bypass for malicious files - HIGH: Enhanced CSRF protection verification - MEDIUM: Input validation and error handling improvements 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
509 lines
No EOL
21 KiB
JavaScript
509 lines
No EOL
21 KiB
JavaScript
const { test, expect } = require('@playwright/test');
|
||
const { HVACTestBase } = require('./page-objects/HVACTestBase');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
|
||
/**
|
||
* Featured Image Upload Test Suite
|
||
*
|
||
* Tests the drag-and-drop image upload system including:
|
||
* - File validation (type, size, format)
|
||
* - Drag and drop functionality
|
||
* - Image preview generation
|
||
* - File replacement and removal
|
||
* - Security validation
|
||
* - Error handling and recovery
|
||
*/
|
||
test.describe('Featured Image Upload System', () => {
|
||
let hvacTest;
|
||
const testImagesDir = path.join(__dirname, 'fixtures', 'images');
|
||
|
||
test.beforeAll(async () => {
|
||
// Create test images directory if it doesn't exist
|
||
if (!fs.existsSync(testImagesDir)) {
|
||
fs.mkdirSync(testImagesDir, { recursive: true });
|
||
}
|
||
|
||
// Create test images
|
||
await createTestImages(testImagesDir);
|
||
});
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
hvacTest = new HVACTestBase(page);
|
||
await hvacTest.loginAsTrainer();
|
||
await hvacTest.navigateToCreateEvent();
|
||
|
||
// Wait for upload component to be ready
|
||
await expect(page.locator('.featured-image-upload')).toBeVisible();
|
||
});
|
||
|
||
test.describe('Basic Upload Functionality', () => {
|
||
test('should initialize upload component correctly', async ({ page }) => {
|
||
// Verify all upload elements are present
|
||
await expect(page.locator('#featured-image-input')).toBeVisible();
|
||
await expect(page.locator('.upload-area')).toBeVisible();
|
||
await expect(page.locator('.upload-placeholder')).toBeVisible();
|
||
|
||
// Verify initial state
|
||
const hasPreview = await page.locator('#image-preview').isVisible();
|
||
expect(hasPreview).toBe(false);
|
||
|
||
// Verify drag and drop indicators
|
||
await expect(page.locator('.drag-drop-text')).toContainText('Drag & drop');
|
||
});
|
||
|
||
test('should upload valid image file', async ({ page }) => {
|
||
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
|
||
|
||
await page.setInputFiles('#featured-image-input', imagePath);
|
||
|
||
// Should show preview
|
||
await expect(page.locator('#image-preview')).toBeVisible();
|
||
await expect(page.locator('#image-preview img')).toBeVisible();
|
||
|
||
// Should show file info
|
||
await expect(page.locator('.image-info .filename')).toContainText('valid-image.jpg');
|
||
|
||
// Should hide upload placeholder
|
||
await expect(page.locator('.upload-placeholder')).not.toBeVisible();
|
||
|
||
// Should show remove button
|
||
await expect(page.locator('.remove-image-btn')).toBeVisible();
|
||
});
|
||
|
||
test('should display image preview with correct dimensions', async ({ page }) => {
|
||
const imagePath = path.join(testImagesDir, 'large-image.jpg');
|
||
|
||
await page.setInputFiles('#featured-image-input', imagePath);
|
||
|
||
await expect(page.locator('#image-preview img')).toBeVisible();
|
||
|
||
// Check preview dimensions are constrained
|
||
const img = page.locator('#image-preview img');
|
||
const { width, height } = await img.boundingBox();
|
||
|
||
expect(width).toBeLessThanOrEqual(400); // Max preview width
|
||
expect(height).toBeLessThanOrEqual(300); // Max preview height
|
||
});
|
||
|
||
test('should show loading state during upload', async ({ page }) => {
|
||
// Slow down the FileReader to test loading state
|
||
await page.addInitScript(() => {
|
||
const originalFileReader = window.FileReader;
|
||
window.FileReader = function() {
|
||
const reader = new originalFileReader();
|
||
const originalReadAsDataURL = reader.readAsDataURL;
|
||
reader.readAsDataURL = function(file) {
|
||
setTimeout(() => {
|
||
originalReadAsDataURL.call(this, file);
|
||
}, 1000); // 1 second delay
|
||
};
|
||
return reader;
|
||
};
|
||
});
|
||
|
||
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
|
||
await page.setInputFiles('#featured-image-input', imagePath);
|
||
|
||
// Should show loading indicator
|
||
await expect(page.locator('.upload-loading')).toBeVisible();
|
||
|
||
// Wait for upload to complete
|
||
await expect(page.locator('#image-preview img')).toBeVisible();
|
||
await expect(page.locator('.upload-loading')).not.toBeVisible();
|
||
});
|
||
});
|
||
|
||
test.describe('File Validation', () => {
|
||
test('should accept valid image formats', async ({ page }) => {
|
||
const validFormats = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||
|
||
for (const format of validFormats) {
|
||
const imagePath = path.join(testImagesDir, `test-image.${format}`);
|
||
|
||
await page.setInputFiles('#featured-image-input', imagePath);
|
||
|
||
// Should show preview for valid formats
|
||
await expect(page.locator('#image-preview img')).toBeVisible();
|
||
|
||
// Clear for next test
|
||
await page.click('.remove-image-btn');
|
||
await expect(page.locator('#image-preview')).not.toBeVisible();
|
||
}
|
||
});
|
||
|
||
test('should reject invalid file types', async ({ page }) => {
|
||
const invalidFiles = [
|
||
'document.pdf',
|
||
'script.js',
|
||
'text-file.txt',
|
||
'video.mp4',
|
||
'audio.mp3'
|
||
];
|
||
|
||
for (const filename of invalidFiles) {
|
||
const filePath = path.join(testImagesDir, filename);
|
||
|
||
await page.setInputFiles('#featured-image-input', filePath);
|
||
|
||
// Should show error message
|
||
await expect(page.locator('.upload-error')).toBeVisible();
|
||
await expect(page.locator('.upload-error')).toContainText('Invalid file type');
|
||
|
||
// Should not show preview
|
||
await expect(page.locator('#image-preview img')).not.toBeVisible();
|
||
}
|
||
});
|
||
|
||
test('should enforce file size limits', async ({ page }) => {
|
||
const oversizedImagePath = path.join(testImagesDir, 'oversized-image.jpg');
|
||
|
||
await page.setInputFiles('#featured-image-input', oversizedImagePath);
|
||
|
||
await expect(page.locator('.upload-error')).toBeVisible();
|
||
await expect(page.locator('.upload-error')).toContainText('File size exceeds 5MB limit');
|
||
});
|
||
|
||
test('should validate actual image content vs extension', async ({ page }) => {
|
||
// File with .jpg extension but actually a text file
|
||
const fakeImagePath = path.join(testImagesDir, 'fake-image.jpg');
|
||
|
||
await page.setInputFiles('#featured-image-input', fakeImagePath);
|
||
|
||
await expect(page.locator('.upload-error')).toContainText('File content does not match extension');
|
||
});
|
||
|
||
test('should handle corrupted image files', async ({ page }) => {
|
||
const corruptedImagePath = path.join(testImagesDir, 'corrupted-image.jpg');
|
||
|
||
await page.setInputFiles('#featured-image-input', corruptedImagePath);
|
||
|
||
// Should show error for corrupted file
|
||
await expect(page.locator('.upload-error')).toContainText('Unable to process image file');
|
||
});
|
||
});
|
||
|
||
test.describe('Drag and Drop Functionality', () => {
|
||
test('should show visual feedback during drag over', async ({ page }) => {
|
||
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
|
||
const file = fs.readFileSync(imagePath);
|
||
|
||
// Create drag data
|
||
const dataTransfer = await page.evaluateHandle((fileData) => {
|
||
const dt = new DataTransfer();
|
||
const file = new File([new Uint8Array(fileData)], 'valid-image.jpg', {
|
||
type: 'image/jpeg'
|
||
});
|
||
dt.items.add(file);
|
||
return dt;
|
||
}, Array.from(file));
|
||
|
||
const uploadArea = page.locator('.upload-area');
|
||
|
||
// Trigger dragenter event
|
||
await uploadArea.dispatchEvent('dragenter', { dataTransfer });
|
||
|
||
// Should show drag-over state
|
||
await expect(uploadArea).toHaveClass(/drag-over/);
|
||
await expect(page.locator('.drag-indicator')).toBeVisible();
|
||
|
||
// Trigger dragleave event
|
||
await uploadArea.dispatchEvent('dragleave', { dataTransfer });
|
||
|
||
// Should remove drag-over state
|
||
await expect(uploadArea).not.toHaveClass(/drag-over/);
|
||
});
|
||
|
||
test('should handle drop event with valid image', async ({ page }) => {
|
||
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
|
||
const file = fs.readFileSync(imagePath);
|
||
|
||
await page.locator('.upload-area').dispatchEvent('drop', {
|
||
dataTransfer: await page.evaluateHandle((fileData) => {
|
||
const dt = new DataTransfer();
|
||
const file = new File([new Uint8Array(fileData)], 'valid-image.jpg', {
|
||
type: 'image/jpeg'
|
||
});
|
||
dt.items.add(file);
|
||
return dt;
|
||
}, Array.from(file))
|
||
});
|
||
|
||
// Should process dropped file
|
||
await expect(page.locator('#image-preview img')).toBeVisible();
|
||
await expect(page.locator('.image-info .filename')).toContainText('valid-image.jpg');
|
||
});
|
||
|
||
test('should handle multiple files dropped (take first valid)', async ({ page }) => {
|
||
const imagePath1 = path.join(testImagesDir, 'image1.jpg');
|
||
const imagePath2 = path.join(testImagesDir, 'image2.jpg');
|
||
const file1 = fs.readFileSync(imagePath1);
|
||
const file2 = fs.readFileSync(imagePath2);
|
||
|
||
await page.locator('.upload-area').dispatchEvent('drop', {
|
||
dataTransfer: await page.evaluateHandle((fileData) => {
|
||
const dt = new DataTransfer();
|
||
const file1 = new File([new Uint8Array(fileData.file1)], 'image1.jpg', {
|
||
type: 'image/jpeg'
|
||
});
|
||
const file2 = new File([new Uint8Array(fileData.file2)], 'image2.jpg', {
|
||
type: 'image/jpeg'
|
||
});
|
||
dt.items.add(file1);
|
||
dt.items.add(file2);
|
||
return dt;
|
||
}, { file1: Array.from(file1), file2: Array.from(file2) })
|
||
});
|
||
|
||
// Should process only the first file
|
||
await expect(page.locator('#image-preview img')).toBeVisible();
|
||
await expect(page.locator('.image-info .filename')).toContainText('image1.jpg');
|
||
|
||
// Should show warning about multiple files
|
||
await expect(page.locator('.upload-warning')).toContainText('Only the first image was selected');
|
||
});
|
||
|
||
test('should prevent default browser behavior on drag events', async ({ page }) => {
|
||
// Monitor for any navigation attempts
|
||
let navigationAttempted = false;
|
||
page.on('framenavigated', () => {
|
||
navigationAttempted = true;
|
||
});
|
||
|
||
const uploadArea = page.locator('.upload-area');
|
||
|
||
// Simulate drag events that might cause navigation
|
||
await uploadArea.dispatchEvent('dragover');
|
||
await uploadArea.dispatchEvent('dragenter');
|
||
await uploadArea.dispatchEvent('drop');
|
||
|
||
await page.waitForTimeout(500);
|
||
|
||
// Should not have attempted navigation
|
||
expect(navigationAttempted).toBe(false);
|
||
});
|
||
});
|
||
|
||
test.describe('Image Management', () => {
|
||
test('should allow image replacement', async ({ page }) => {
|
||
const firstImage = path.join(testImagesDir, 'image1.jpg');
|
||
const secondImage = path.join(testImagesDir, 'image2.jpg');
|
||
|
||
// Upload first image
|
||
await page.setInputFiles('#featured-image-input', firstImage);
|
||
await expect(page.locator('.image-info .filename')).toContainText('image1.jpg');
|
||
|
||
// Upload second image (should replace first)
|
||
await page.setInputFiles('#featured-image-input', secondImage);
|
||
await expect(page.locator('.image-info .filename')).toContainText('image2.jpg');
|
||
|
||
// Should only have one preview
|
||
const previewCount = await page.locator('#image-preview img').count();
|
||
expect(previewCount).toBe(1);
|
||
});
|
||
|
||
test('should allow image removal', async ({ page }) => {
|
||
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
|
||
|
||
await page.setInputFiles('#featured-image-input', imagePath);
|
||
await expect(page.locator('#image-preview img')).toBeVisible();
|
||
|
||
// Click remove button
|
||
await page.click('.remove-image-btn');
|
||
|
||
// Should hide preview and restore upload area
|
||
await expect(page.locator('#image-preview')).not.toBeVisible();
|
||
await expect(page.locator('.upload-placeholder')).toBeVisible();
|
||
|
||
// Should clear file input
|
||
const inputValue = await page.locator('#featured-image-input').inputValue();
|
||
expect(inputValue).toBe('');
|
||
});
|
||
|
||
test('should show image metadata', async ({ page }) => {
|
||
const imagePath = path.join(testImagesDir, 'detailed-image.jpg');
|
||
|
||
await page.setInputFiles('#featured-image-input', imagePath);
|
||
|
||
// Should show file information
|
||
await expect(page.locator('.image-info .filename')).toBeVisible();
|
||
await expect(page.locator('.image-info .filesize')).toBeVisible();
|
||
await expect(page.locator('.image-info .dimensions')).toBeVisible();
|
||
|
||
// Verify information format
|
||
const filesize = await page.locator('.image-info .filesize').textContent();
|
||
expect(filesize).toMatch(/\d+(\.\d+)?\s?(KB|MB)/);
|
||
|
||
const dimensions = await page.locator('.image-info .dimensions').textContent();
|
||
expect(dimensions).toMatch(/\d+\s?×\s?\d+/);
|
||
});
|
||
|
||
test('should handle image orientation correctly', async ({ page }) => {
|
||
const portraitPath = path.join(testImagesDir, 'portrait-image.jpg');
|
||
const landscapePath = path.join(testImagesDir, 'landscape-image.jpg');
|
||
|
||
// Test portrait image
|
||
await page.setInputFiles('#featured-image-input', portraitPath);
|
||
await expect(page.locator('#image-preview img')).toBeVisible();
|
||
|
||
let imgBox = await page.locator('#image-preview img').boundingBox();
|
||
expect(imgBox.height).toBeGreaterThan(imgBox.width);
|
||
|
||
await page.click('.remove-image-btn');
|
||
|
||
// Test landscape image
|
||
await page.setInputFiles('#featured-image-input', landscapePath);
|
||
await expect(page.locator('#image-preview img')).toBeVisible();
|
||
|
||
imgBox = await page.locator('#image-preview img').boundingBox();
|
||
expect(imgBox.width).toBeGreaterThan(imgBox.height);
|
||
});
|
||
});
|
||
|
||
test.describe('Error Handling', () => {
|
||
test('should handle FileReader errors gracefully', async ({ page }) => {
|
||
// Mock FileReader to throw an error
|
||
await page.addInitScript(() => {
|
||
const originalFileReader = window.FileReader;
|
||
window.FileReader = function() {
|
||
const reader = new originalFileReader();
|
||
const originalReadAsDataURL = reader.readAsDataURL;
|
||
reader.readAsDataURL = function(file) {
|
||
setTimeout(() => {
|
||
if (this.onerror) {
|
||
this.onerror(new Error('FileReader error'));
|
||
}
|
||
}, 100);
|
||
};
|
||
return reader;
|
||
};
|
||
});
|
||
|
||
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
|
||
await page.setInputFiles('#featured-image-input', imagePath);
|
||
|
||
await expect(page.locator('.upload-error')).toContainText('Error processing image file');
|
||
});
|
||
|
||
test('should recover from upload errors', async ({ page }) => {
|
||
const invalidPath = path.join(testImagesDir, 'invalid-file.txt');
|
||
|
||
// Try invalid file first
|
||
await page.setInputFiles('#featured-image-input', invalidPath);
|
||
await expect(page.locator('.upload-error')).toBeVisible();
|
||
|
||
// Should be able to upload valid file after error
|
||
const validPath = path.join(testImagesDir, 'valid-image.jpg');
|
||
await page.setInputFiles('#featured-image-input', validPath);
|
||
|
||
await expect(page.locator('.upload-error')).not.toBeVisible();
|
||
await expect(page.locator('#image-preview img')).toBeVisible();
|
||
});
|
||
|
||
test('should handle network errors during upload', async ({ page }) => {
|
||
// Mock network failure for any upload requests
|
||
await page.route('**/*upload*', route => route.abort());
|
||
|
||
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
|
||
await page.setInputFiles('#featured-image-input', imagePath);
|
||
|
||
// Should handle network error gracefully
|
||
await expect(page.locator('.upload-error')).toContainText('Upload failed');
|
||
});
|
||
|
||
test('should validate against MIME type spoofing', async ({ page }) => {
|
||
// File with image extension but text content
|
||
const spoofedPath = path.join(testImagesDir, 'spoofed-image.jpg');
|
||
|
||
await page.setInputFiles('#featured-image-input', spoofedPath);
|
||
|
||
await expect(page.locator('.upload-error')).toContainText('File content validation failed');
|
||
});
|
||
});
|
||
|
||
test.describe('Accessibility', () => {
|
||
test('should support keyboard navigation', async ({ page }) => {
|
||
// Focus should move to file input
|
||
await page.keyboard.press('Tab');
|
||
await page.keyboard.press('Tab'); // Navigate to upload area
|
||
|
||
const focused = await page.evaluate(() =>
|
||
document.activeElement.classList.contains('upload-area') ||
|
||
document.activeElement.id === 'featured-image-input'
|
||
);
|
||
expect(focused).toBe(true);
|
||
|
||
// Enter key should trigger file dialog
|
||
await page.keyboard.press('Enter');
|
||
// File dialog behavior varies by browser - just ensure no errors
|
||
});
|
||
|
||
test('should have proper ARIA labels', async ({ page }) => {
|
||
const uploadArea = page.locator('.upload-area');
|
||
const fileInput = page.locator('#featured-image-input');
|
||
|
||
// Check for accessibility attributes
|
||
await expect(uploadArea).toHaveAttribute('role', 'button');
|
||
await expect(uploadArea).toHaveAttribute('aria-label');
|
||
await expect(fileInput).toHaveAttribute('aria-describedby');
|
||
});
|
||
|
||
test('should announce upload status to screen readers', async ({ page }) => {
|
||
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
|
||
|
||
// Monitor for aria-live announcements
|
||
const announcements = [];
|
||
page.on('console', msg => {
|
||
if (msg.text().includes('aria-live')) {
|
||
announcements.push(msg.text());
|
||
}
|
||
});
|
||
|
||
await page.setInputFiles('#featured-image-input', imagePath);
|
||
|
||
// Should announce successful upload
|
||
await expect(page.locator('[aria-live="polite"]')).toContainText('Image uploaded successfully');
|
||
});
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Helper function to create test image files
|
||
*/
|
||
async function createTestImages(dir) {
|
||
// Create minimal valid JPEG data
|
||
const validJpeg = Buffer.from([
|
||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46,
|
||
0x49, 0x46, 0x00, 0x01, 0x01, 0x01, 0x00, 0x48,
|
||
0x00, 0x48, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
|
||
// ... truncated for brevity - minimal JPEG data
|
||
0xFF, 0xD9
|
||
]);
|
||
|
||
const testFiles = {
|
||
'valid-image.jpg': validJpeg,
|
||
'test-image.jpeg': validJpeg,
|
||
'test-image.png': Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), // PNG header
|
||
'test-image.gif': Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // GIF header
|
||
'test-image.webp': Buffer.from([0x52, 0x49, 0x46, 0x46]), // WEBP header
|
||
'large-image.jpg': Buffer.concat([validJpeg, Buffer.alloc(1024 * 1024)]), // ~1MB
|
||
'oversized-image.jpg': Buffer.alloc(6 * 1024 * 1024), // 6MB
|
||
'fake-image.jpg': Buffer.from('This is not an image file'),
|
||
'corrupted-image.jpg': Buffer.from([0xFF, 0xD8, 0x00, 0x00]), // Invalid JPEG
|
||
'document.pdf': Buffer.from('%PDF-1.4'),
|
||
'script.js': Buffer.from('alert("test");'),
|
||
'text-file.txt': Buffer.from('Plain text content'),
|
||
'video.mp4': Buffer.from('ftypmp42'),
|
||
'audio.mp3': Buffer.from('ID3'),
|
||
'image1.jpg': validJpeg,
|
||
'image2.jpg': validJpeg,
|
||
'detailed-image.jpg': validJpeg,
|
||
'portrait-image.jpg': validJpeg,
|
||
'landscape-image.jpg': validJpeg,
|
||
'spoofed-image.jpg': Buffer.from('<script>alert("XSS")</script>')
|
||
};
|
||
|
||
for (const [filename, data] of Object.entries(testFiles)) {
|
||
fs.writeFileSync(path.join(dir, filename), data);
|
||
}
|
||
} |