🛡️ Sentinel: Add honeypot field to Contact form #65

Closed
ragusa-it wants to merge 1 commits from sentinel-add-honeypot-5671633421356179949 into main
3 changed files with 81 additions and 17 deletions

View File

@@ -139,3 +139,17 @@
.infoItem a:hover { .infoItem a:hover {
text-decoration: underline; 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;
}

View File

@@ -35,6 +35,7 @@ export function Contact() {
subject: "", subject: "",
message: "", message: "",
}); });
const [honeypot, setHoneypot] = useState("");
const [errors, setErrors] = useState<FormErrors>({}); const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState< const [submitStatus, setSubmitStatus] = useState<
@@ -79,6 +80,15 @@ export function Contact() {
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); 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); setRateLimitError(false);
if (!validateForm()) return; if (!validateForm()) return;
@@ -166,6 +176,19 @@ export function Contact() {
className={styles.form} className={styles.form}
noValidate 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 <Input
label={t.contact.form.name} label={t.contact.form.name}
required required

View File

@@ -70,10 +70,10 @@ describe('Contact Page', () => {
render(<Contact />); render(<Contact />);
// Fill out the form // Fill out the form
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'John Doe' } }); fireEvent.change(screen.getByPlaceholderText('Your Name'), { target: { value: 'John Doe' } });
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'john@example.com' } }); fireEvent.change(screen.getByPlaceholderText('Your Email'), { target: { value: 'john@example.com' } });
fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Test Subject' } }); fireEvent.change(screen.getByPlaceholderText('Message Subject'), { target: { value: 'Test Subject' } });
fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Hello world' } }); fireEvent.change(screen.getByPlaceholderText('Your Message'), { target: { value: 'Hello world' } });
// Submit // Submit
fireEvent.click(screen.getByRole('button', { name: 'Send Message' })); fireEvent.click(screen.getByRole('button', { name: 'Send Message' }));
@@ -124,10 +124,10 @@ describe('Contact Page', () => {
render(<Contact />); render(<Contact />);
// Fill out with malicious input // Fill out with malicious input
fireEvent.change(screen.getByLabelText('Name'), { target: { value: '<script>alert(1)</script>' } }); fireEvent.change(screen.getByPlaceholderText('Your Name'), { target: { value: '<script>alert(1)</script>' } });
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'john@example.com' } }); fireEvent.change(screen.getByPlaceholderText('Your Email'), { target: { value: 'john@example.com' } });
fireEvent.change(screen.getByLabelText('Subject'), { target: { value: '<b>Bold</b>' } }); fireEvent.change(screen.getByPlaceholderText('Message Subject'), { target: { value: '<b>Bold</b>' } });
fireEvent.change(screen.getByLabelText('Message'), { target: { value: '"Quotes"' } }); fireEvent.change(screen.getByPlaceholderText('Your Message'), { target: { value: '"Quotes"' } });
// Submit // Submit
fireEvent.click(screen.getByRole('button', { name: 'Send Message' })); fireEvent.click(screen.getByRole('button', { name: 'Send Message' }));
@@ -161,7 +161,7 @@ describe('Contact Page', () => {
const longName = 'a'.repeat(101); const longName = 'a'.repeat(101);
// Fill out the form // Fill out the form
fireEvent.change(screen.getByLabelText('Name'), { target: { value: longName } }); fireEvent.change(screen.getByPlaceholderText('Your Name'), { target: { value: longName } });
// Submit // Submit
fireEvent.click(screen.getByRole('button', { name: 'Send Message' })); fireEvent.click(screen.getByRole('button', { name: 'Send Message' }));
@@ -178,10 +178,10 @@ describe('Contact Page', () => {
const { container } = render(<Contact />); const { container } = render(<Contact />);
// Fill out the form with invalid email (XSS vector) // Fill out the form with invalid email (XSS vector)
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'John Doe' } }); fireEvent.change(screen.getByPlaceholderText('Your Name'), { target: { value: 'John Doe' } });
fireEvent.change(screen.getByLabelText('Email'), { target: { value: '<script>@example.com' } }); fireEvent.change(screen.getByPlaceholderText('Your Email'), { target: { value: '<script>@example.com' } });
fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Test Subject' } }); fireEvent.change(screen.getByPlaceholderText('Message Subject'), { target: { value: 'Test Subject' } });
fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Hello world' } }); 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) // 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 // This ensures our application-level validation logic (isValidEmail) is tested
@@ -204,10 +204,10 @@ describe('Contact Page', () => {
render(<Contact />); render(<Contact />);
// Fill out the form // Fill out the form
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'John Doe' } }); fireEvent.change(screen.getByPlaceholderText('Your Name'), { target: { value: 'John Doe' } });
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'john@example.com' } }); fireEvent.change(screen.getByPlaceholderText('Your Email'), { target: { value: 'john@example.com' } });
fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Test Subject' } }); fireEvent.change(screen.getByPlaceholderText('Message Subject'), { target: { value: 'Test Subject' } });
fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Hello world' } }); fireEvent.change(screen.getByPlaceholderText('Your Message'), { target: { value: 'Hello world' } });
// Submit // Submit
fireEvent.click(screen.getByRole('button', { name: 'Send Message' })); fireEvent.click(screen.getByRole('button', { name: 'Send Message' }));
@@ -223,4 +223,31 @@ describe('Contact Page', () => {
expect(errorMessage.getAttribute('role')).toBe('alert'); expect(errorMessage.getAttribute('role')).toBe('alert');
expect(errorMessage.getAttribute('aria-live')).toBe('polite'); 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();
});
}); });