diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..06e9224 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2025-02-12 - Missing Test Cleanup +**Vulnerability:** Tests in `src/pages/__tests__/Contact.test.tsx` were failing with duplicate elements because `cleanup()` was not being called between tests. +**Learning:** `testing-library/react` usually handles cleanup automatically, but in this environment/setup, explicit `cleanup()` and `document.body.innerHTML = ''` in `afterEach` is required to prevent DOM state leakage. +**Prevention:** Always include explicit `cleanup()` in `afterEach` blocks when writing component tests in this repository. diff --git a/src/pages/Contact.tsx b/src/pages/Contact.tsx index cca7807..ef7bc33 100644 --- a/src/pages/Contact.tsx +++ b/src/pages/Contact.tsx @@ -4,8 +4,13 @@ import emailjs from "@emailjs/browser"; import { useTranslation } from "../i18n"; import { config } from "../config"; import { Button, Input, Textarea } from "../components/ui"; +import { sanitizeInput } from "../utils/security"; import styles from "./Contact.module.css"; +const NAME_MAX_LENGTH = 100; +const SUBJECT_MAX_LENGTH = 200; +const MESSAGE_MAX_LENGTH = 5000; + interface FormData { name: string; email: string; @@ -39,6 +44,8 @@ export function Contact() { if (!formData.name.trim()) { newErrors.name = "Required"; + } else if (formData.name.length > NAME_MAX_LENGTH) { + newErrors.name = `Max ${NAME_MAX_LENGTH} characters`; } if (!formData.email.trim()) { @@ -49,10 +56,14 @@ export function Contact() { if (!formData.subject.trim()) { newErrors.subject = "Required"; + } else if (formData.subject.length > SUBJECT_MAX_LENGTH) { + newErrors.subject = `Max ${SUBJECT_MAX_LENGTH} characters`; } if (!formData.message.trim()) { newErrors.message = "Required"; + } else if (formData.message.length > MESSAGE_MAX_LENGTH) { + newErrors.message = `Max ${MESSAGE_MAX_LENGTH} characters`; } setErrors(newErrors); @@ -69,10 +80,10 @@ export function Contact() { try { const templateParams = { - name: formData.name, - email: formData.email, - title: formData.subject, - message: formData.message, + name: sanitizeInput(formData.name), + email: formData.email, // Email doesn't typically need HTML sanitization if validated by regex, but good practice to handle it if used in HTML context. + title: sanitizeInput(formData.subject), + message: sanitizeInput(formData.message), reply_to: formData.email, }; diff --git a/src/pages/__tests__/Contact.test.tsx b/src/pages/__tests__/Contact.test.tsx index 515eebb..45780de 100644 --- a/src/pages/__tests__/Contact.test.tsx +++ b/src/pages/__tests__/Contact.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Contact } from '../Contact'; import emailjs from '@emailjs/browser'; @@ -60,6 +60,11 @@ describe('Contact Page', () => { vi.clearAllMocks(); }); + afterEach(() => { + cleanup(); + document.body.innerHTML = ''; + }); + it('submits the form with correct parameters', async () => { render(); @@ -111,4 +116,58 @@ describe('Contact Page', () => { const successMessage = await screen.findByText('Message sent successfully!'); expect(successMessage).toBeTruthy(); }); + + it('sanitizes input before sending', async () => { + render(); + + // Fill out with malicious input + fireEvent.change(screen.getByLabelText('Name'), { target: { value: '' } }); + fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'john@example.com' } }); + fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Bold' } }); + fireEvent.change(screen.getByLabelText('Message'), { target: { value: '"Quotes"' } }); + + // Submit + fireEvent.click(screen.getByRole('button', { name: 'Send Message' })); + + // Wait for submission + await waitFor(() => { + expect(emailjs.send).toHaveBeenCalled(); + }); + + const expectedParams = { + name: '<script>alert(1)</script>', + email: 'john@example.com', + title: '<b>Bold</b>', + message: '"Quotes"', + reply_to: 'john@example.com', + }; + + // Check first call + expect(emailjs.send).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expectedParams, + expect.any(Object) + ); + }); + + it('shows error when input exceeds max length', async () => { + render(); + + // Create a long string (101 characters) + const longName = 'a'.repeat(101); + + // Fill out the form + fireEvent.change(screen.getByLabelText('Name'), { target: { value: longName } }); + + // Submit + fireEvent.click(screen.getByRole('button', { name: 'Send Message' })); + + // Validation error should appear + const errorMessage = await screen.findByText('Max 100 characters'); + expect(errorMessage).toBeTruthy(); + + // EmailJS should NOT be called + expect(emailjs.send).not.toHaveBeenCalled(); + }); }); diff --git a/src/utils/security.ts b/src/utils/security.ts new file mode 100644 index 0000000..3d04c49 --- /dev/null +++ b/src/utils/security.ts @@ -0,0 +1,18 @@ +/** + * Sanitizes user input by encoding special HTML characters. + * Prevents XSS attacks by ensuring input is treated as text, not HTML. + * + * @param input - The raw string input from the user. + * @returns The sanitized string with special characters encoded. + */ +export function sanitizeInput(input: string): string { + if (typeof input !== "string") { + return input; + } + return input + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +}