Merge pull request #17 from ragusa-it/sentinel-contact-form-security-13396691456148792037
🛡️ Sentinel: Add input sanitization and validation to Contact form
This commit was merged in pull request #17.
This commit is contained in:
4
.jules/sentinel.md
Normal file
4
.jules/sentinel.md
Normal 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.
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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: '<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(<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
18
src/utils/security.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
Reference in New Issue
Block a user