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 { useTranslation } from "../i18n";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
import { Button, Input, Textarea } from "../components/ui";
|
import { Button, Input, Textarea } from "../components/ui";
|
||||||
|
import { sanitizeInput } from "../utils/security";
|
||||||
import styles from "./Contact.module.css";
|
import styles from "./Contact.module.css";
|
||||||
|
|
||||||
|
const NAME_MAX_LENGTH = 100;
|
||||||
|
const SUBJECT_MAX_LENGTH = 200;
|
||||||
|
const MESSAGE_MAX_LENGTH = 5000;
|
||||||
|
|
||||||
interface FormData {
|
interface FormData {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -39,6 +44,8 @@ export function Contact() {
|
|||||||
|
|
||||||
if (!formData.name.trim()) {
|
if (!formData.name.trim()) {
|
||||||
newErrors.name = "Required";
|
newErrors.name = "Required";
|
||||||
|
} else if (formData.name.length > NAME_MAX_LENGTH) {
|
||||||
|
newErrors.name = `Max ${NAME_MAX_LENGTH} characters`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.email.trim()) {
|
if (!formData.email.trim()) {
|
||||||
@@ -49,10 +56,14 @@ export function Contact() {
|
|||||||
|
|
||||||
if (!formData.subject.trim()) {
|
if (!formData.subject.trim()) {
|
||||||
newErrors.subject = "Required";
|
newErrors.subject = "Required";
|
||||||
|
} else if (formData.subject.length > SUBJECT_MAX_LENGTH) {
|
||||||
|
newErrors.subject = `Max ${SUBJECT_MAX_LENGTH} characters`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.message.trim()) {
|
if (!formData.message.trim()) {
|
||||||
newErrors.message = "Required";
|
newErrors.message = "Required";
|
||||||
|
} else if (formData.message.length > MESSAGE_MAX_LENGTH) {
|
||||||
|
newErrors.message = `Max ${MESSAGE_MAX_LENGTH} characters`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
@@ -69,10 +80,10 @@ export function Contact() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const templateParams = {
|
const templateParams = {
|
||||||
name: formData.name,
|
name: sanitizeInput(formData.name),
|
||||||
email: formData.email,
|
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: formData.subject,
|
title: sanitizeInput(formData.subject),
|
||||||
message: formData.message,
|
message: sanitizeInput(formData.message),
|
||||||
reply_to: formData.email,
|
reply_to: formData.email,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
||||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { Contact } from '../Contact';
|
import { Contact } from '../Contact';
|
||||||
import emailjs from '@emailjs/browser';
|
import emailjs from '@emailjs/browser';
|
||||||
|
|
||||||
@@ -60,6 +60,11 @@ describe('Contact Page', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
it('submits the form with correct parameters', async () => {
|
it('submits the form with correct parameters', async () => {
|
||||||
render(<Contact />);
|
render(<Contact />);
|
||||||
|
|
||||||
@@ -111,4 +116,58 @@ describe('Contact Page', () => {
|
|||||||
const successMessage = await screen.findByText('Message sent successfully!');
|
const successMessage = await screen.findByText('Message sent successfully!');
|
||||||
expect(successMessage).toBeTruthy();
|
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