🛡️ Sentinel: Add Honeypot to Contact Form #51

Closed
ragusa-it wants to merge 1 commits from sentinel-honeypot-fix-4259546573877746528 into main
4 changed files with 99 additions and 22 deletions
Showing only changes of commit 7ef5c5f779 - Show all commits

View File

@@ -27,3 +27,8 @@
**Vulnerability:** Allowing users to register or submit forms with disposable email addresses (e.g., mailinator.com) can lead to spam, abuse, and polluted data.
**Learning:** While true email verification requires a backend or API, a simple client-side blocklist of common disposable domains is a highly effective, low-cost first line of defense.
**Prevention:** Maintain a list of known disposable domains (e.g., `BLOCKED_DOMAINS`) and check the domain part of the email address during validation.
## 2026-02-14 - Blocked Domains in Test Data
**Vulnerability:** Tests failing unexpectedly on valid inputs can lead to disabling security features or ignoring real bugs.
**Learning:** The application blocks specific domains (like `example.com`) in its validation logic. Using these domains in test data (e.g. `john@example.com`) causes tests to fail with "Invalid email", confusing debugging efforts.
**Prevention:** Always use safe, non-blocked domains (e.g. `valid-email.com` or a custom test domain) in test fixtures, and check the security configuration (`BLOCKED_DOMAINS`) when validation tests fail unexpectedly.

View File

@@ -139,3 +139,14 @@
.infoItem a:hover {
text-decoration: underline;
}
.honeypot {
opacity: 0;
position: absolute;
top: 0;
left: 0;
height: 0;
width: 0;
z-index: -1;
overflow: hidden;
}

View File

@@ -18,6 +18,7 @@ interface FormData {
email: string;
subject: string;
message: string;
website: string; // Honeypot
}
interface FormErrors {
@@ -25,6 +26,7 @@ interface FormErrors {
email?: string;
subject?: string;
message?: string;
website?: string;
}
export function Contact() {
@@ -34,6 +36,7 @@ export function Contact() {
email: "",
subject: "",
message: "",
website: "",
});
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -87,6 +90,19 @@ export function Contact() {
return;
}
// Honeypot check - if filled, silently succeed
if (formData.website) {
setSubmitStatus("success");
setFormData({
name: "",
email: "",
subject: "",
message: "",
website: "",
});
return;
}
setIsSubmitting(true);
setSubmitStatus("idle");
@@ -116,7 +132,13 @@ export function Contact() {
);
setSubmitStatus("success");
setFormData({ name: "", email: "", subject: "", message: "" });
setFormData({
name: "",
email: "",
subject: "",
message: "",
website: "",
});
} catch (error) {
console.error("EmailJS Error:", error);
setSubmitStatus("error");
@@ -208,6 +230,20 @@ export function Contact() {
maxLength={MESSAGE_MAX_LENGTH}
/>
{/* Honeypot field - invisible to users */}
<div className={styles.honeypot} aria-hidden="true">
<label htmlFor="website">Website</label>
<input
type="text"
id="website"
name="website"
value={formData.website}
onChange={(e) => handleChange("website", e.target.value)}
tabIndex={-1}
autoComplete="off"
/>
</div>
<Button
type="submit"
variant="primary"

View File

@@ -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@valid-email.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' }));
@@ -85,10 +85,10 @@ describe('Contact Page', () => {
const expectedParams = {
name: 'John Doe',
email: 'john@example.com',
email: 'john@valid-email.com',
title: 'Test Subject',
message: 'Hello world',
reply_to: 'john@example.com',
reply_to: 'john@valid-email.com',
};
const expectedOptions = {
@@ -120,14 +120,39 @@ describe('Contact Page', () => {
expect(successMessage.getAttribute('aria-live')).toBe('polite');
});
it('silently rejects submission when honeypot field is filled', async () => {
render(<Contact />);
// Fill out the form with valid data
fireEvent.change(screen.getByPlaceholderText('Your Name'), { target: { value: 'John Doe' } });
fireEvent.change(screen.getByPlaceholderText('Your Email'), { target: { value: 'john@valid-email.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 (Website)
// Note: The honeypot label is "Website"
fireEvent.change(screen.getByLabelText('Website'), { target: { value: 'http://spambot.com' } });
// Submit
fireEvent.click(screen.getByRole('button', { name: 'Send Message' }));
// Wait for what appears to be a success (silent rejection)
const successMessage = await screen.findByText('Message sent successfully!');
expect(successMessage).toBeTruthy();
expect(successMessage.getAttribute('role')).toBe('alert');
// BUT EmailJS should NOT be called
expect(emailjs.send).not.toHaveBeenCalled();
});
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"' } });
fireEvent.change(screen.getByPlaceholderText('Your Name'), { target: { value: '<script>alert(1)</script>' } });
fireEvent.change(screen.getByPlaceholderText('Your Email'), { target: { value: 'john@valid-email.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' }));
@@ -139,10 +164,10 @@ describe('Contact Page', () => {
const expectedParams = {
name: '&lt;script&gt;alert(1)&lt;/script&gt;',
email: 'john@example.com',
email: 'john@valid-email.com',
title: '&lt;b&gt;Bold&lt;/b&gt;',
message: '&quot;Quotes&quot;',
reply_to: 'john@example.com',
reply_to: 'john@valid-email.com',
};
// Check first call
@@ -161,7 +186,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 +203,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 +229,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@valid-email.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' }));