Shield: Add input sanitization and length validation to Contact form

Added `sanitizeInput` utility to escape HTML characters.
Updated `Contact.tsx` to sanitize inputs before sending via `emailjs`.
Added max length validation for Name (100), Subject (200), and Message (5000).
Updated tests to cover sanitization and validation logic, including adding `cleanup()` to prevent test leakage.
This commit is contained in:
google-labs-jules[bot]
2026-01-24 10:05:33 +00:00
parent 77fd62447c
commit 6801682c2e
4 changed files with 98 additions and 6 deletions

4
.jules/sentinel.md Normal file
View File

@@ -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.

View File

@@ -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,
};

View File

@@ -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(<Contact />);
@@ -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(<Contact />);
// Fill out with malicious input
fireEvent.change(screen.getByLabelText('Name'), { target: { value: '<script>alert(1)</script>' } });
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'john@example.com' } });
fireEvent.change(screen.getByLabelText('Subject'), { target: { value: '<b>Bold</b>' } });
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: '&lt;script&gt;alert(1)&lt;/script&gt;',
email: 'john@example.com',
title: '&lt;b&gt;Bold&lt;/b&gt;',
message: '&quot;Quotes&quot;',
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(<Contact />);
// 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();
});
});

18
src/utils/security.ts Normal file
View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}