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, "'");
+}