feat(security): add honeypot field to contact form
Added a hidden input field (honeypot) to the Contact form to detect and block automated bot submissions. This enhancement improves availability and integrity by reducing spam without affecting legitimate users. Also updated Contact.test.tsx to include a test case for the honeypot and improve selector robustness. Co-authored-by: ragusa-it <196988693+ragusa-it@users.noreply.github.com>
This commit is contained in:
@@ -139,3 +139,17 @@
|
||||
.infoItem a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.honeypot {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export function Contact() {
|
||||
subject: "",
|
||||
message: "",
|
||||
});
|
||||
const [honeypot, setHoneypot] = useState("");
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitStatus, setSubmitStatus] = useState<
|
||||
@@ -79,6 +80,15 @@ export function Contact() {
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Honeypot check: If the hidden field is filled, it's a bot.
|
||||
// We silently "succeed" to fool the bot and protect our resources.
|
||||
if (honeypot) {
|
||||
setSubmitStatus("success");
|
||||
setFormData({ name: "", email: "", subject: "", message: "" });
|
||||
setHoneypot("");
|
||||
return;
|
||||
}
|
||||
|
||||
setRateLimitError(false);
|
||||
if (!validateForm()) return;
|
||||
|
||||
@@ -166,6 +176,19 @@ export function Contact() {
|
||||
className={styles.form}
|
||||
noValidate
|
||||
>
|
||||
{/* Honeypot field - invisible to users, tempting to bots */}
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
value={honeypot}
|
||||
onChange={(e) => setHoneypot(e.target.value)}
|
||||
className={styles.honeypot}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
data-testid="honeypot-input"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t.contact.form.name}
|
||||
required
|
||||
|
||||
@@ -70,10 +70,10 @@ describe('Contact Page', () => {
|
||||
render(<Contact />);
|
||||
|
||||
// Fill out the form
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'John Doe' } });
|
||||
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'john@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Test Subject' } });
|
||||
fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Hello world' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Name'), { target: { value: 'John Doe' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Email'), { target: { value: 'john@example.com' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Message Subject'), { target: { value: 'Test Subject' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Message'), { target: { value: 'Hello world' } });
|
||||
|
||||
// Submit
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Message' }));
|
||||
@@ -124,10 +124,10 @@ describe('Contact Page', () => {
|
||||
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"' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Name'), { target: { value: '<script>alert(1)</script>' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Email'), { target: { value: 'john@example.com' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Message Subject'), { target: { value: '<b>Bold</b>' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Message'), { target: { value: '"Quotes"' } });
|
||||
|
||||
// Submit
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Message' }));
|
||||
@@ -161,7 +161,7 @@ describe('Contact Page', () => {
|
||||
const longName = 'a'.repeat(101);
|
||||
|
||||
// Fill out the form
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: longName } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Name'), { target: { value: longName } });
|
||||
|
||||
// Submit
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Message' }));
|
||||
@@ -178,10 +178,10 @@ describe('Contact Page', () => {
|
||||
const { container } = render(<Contact />);
|
||||
|
||||
// Fill out the form with invalid email (XSS vector)
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'John Doe' } });
|
||||
fireEvent.change(screen.getByLabelText('Email'), { target: { value: '<script>@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Test Subject' } });
|
||||
fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Hello world' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Name'), { target: { value: 'John Doe' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Email'), { target: { value: '<script>@example.com' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Message Subject'), { target: { value: 'Test Subject' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Message'), { target: { value: 'Hello world' } });
|
||||
|
||||
// Submit via form submit event to bypass browser validation (jsdom/browser would block this otherwise)
|
||||
// This ensures our application-level validation logic (isValidEmail) is tested
|
||||
@@ -204,10 +204,10 @@ describe('Contact Page', () => {
|
||||
render(<Contact />);
|
||||
|
||||
// Fill out the form
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'John Doe' } });
|
||||
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'john@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Test Subject' } });
|
||||
fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Hello world' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Name'), { target: { value: 'John Doe' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Email'), { target: { value: 'john@example.com' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Message Subject'), { target: { value: 'Test Subject' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Message'), { target: { value: 'Hello world' } });
|
||||
|
||||
// Submit
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Message' }));
|
||||
@@ -223,4 +223,31 @@ describe('Contact Page', () => {
|
||||
expect(errorMessage.getAttribute('role')).toBe('alert');
|
||||
expect(errorMessage.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('silently blocks submission if honeypot field is filled (bot detection)', async () => {
|
||||
render(<Contact />);
|
||||
|
||||
// Fill out the form correctly
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Name'), { target: { value: 'John Doe' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Email'), { target: { value: 'john@example.com' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Message Subject'), { target: { value: 'Test Subject' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('Your Message'), { target: { value: 'Hello world' } });
|
||||
|
||||
// Fill the honeypot field
|
||||
const honeypot = screen.getByTestId('honeypot-input');
|
||||
fireEvent.change(honeypot, { target: { value: 'I am a bot' } });
|
||||
|
||||
// Submit
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Message' }));
|
||||
|
||||
// Wait for potential async actions
|
||||
await waitFor(() => {
|
||||
// EmailJS should NOT be called
|
||||
expect(emailjs.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The form should show success to fool the bot
|
||||
const successMessage = await screen.findByText('Message sent successfully!');
|
||||
expect(successMessage).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user