🛡️ Sentinel: Add Honeypot to Contact Form #51
@@ -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.
|
**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.
|
**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.
|
**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.
|
||||||
|
|||||||
@@ -139,3 +139,14 @@
|
|||||||
.infoItem a:hover {
|
.infoItem a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.honeypot {
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
z-index: -1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface FormData {
|
|||||||
email: string;
|
email: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
website: string; // Honeypot
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormErrors {
|
interface FormErrors {
|
||||||
@@ -25,6 +26,7 @@ interface FormErrors {
|
|||||||
email?: string;
|
email?: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
website?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Contact() {
|
export function Contact() {
|
||||||
@@ -34,6 +36,7 @@ export function Contact() {
|
|||||||
email: "",
|
email: "",
|
||||||
subject: "",
|
subject: "",
|
||||||
message: "",
|
message: "",
|
||||||
|
website: "",
|
||||||
});
|
});
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@@ -87,6 +90,19 @@ export function Contact() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Honeypot check - if filled, silently succeed
|
||||||
|
if (formData.website) {
|
||||||
|
setSubmitStatus("success");
|
||||||
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
subject: "",
|
||||||
|
message: "",
|
||||||
|
website: "",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setSubmitStatus("idle");
|
setSubmitStatus("idle");
|
||||||
|
|
||||||
@@ -116,7 +132,13 @@ export function Contact() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setSubmitStatus("success");
|
setSubmitStatus("success");
|
||||||
setFormData({ name: "", email: "", subject: "", message: "" });
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
subject: "",
|
||||||
|
message: "",
|
||||||
|
website: "",
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("EmailJS Error:", error);
|
console.error("EmailJS Error:", error);
|
||||||
setSubmitStatus("error");
|
setSubmitStatus("error");
|
||||||
@@ -208,6 +230,20 @@ export function Contact() {
|
|||||||
maxLength={MESSAGE_MAX_LENGTH}
|
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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|||||||
@@ -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@valid-email.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' }));
|
||||||
@@ -85,10 +85,10 @@ describe('Contact Page', () => {
|
|||||||
|
|
||||||
const expectedParams = {
|
const expectedParams = {
|
||||||
name: 'John Doe',
|
name: 'John Doe',
|
||||||
email: 'john@example.com',
|
email: 'john@valid-email.com',
|
||||||
title: 'Test Subject',
|
title: 'Test Subject',
|
||||||
message: 'Hello world',
|
message: 'Hello world',
|
||||||
reply_to: 'john@example.com',
|
reply_to: 'john@valid-email.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
const expectedOptions = {
|
const expectedOptions = {
|
||||||
@@ -120,14 +120,39 @@ describe('Contact Page', () => {
|
|||||||
expect(successMessage.getAttribute('aria-live')).toBe('polite');
|
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 () => {
|
it('sanitizes input before sending', async () => {
|
||||||
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@valid-email.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' }));
|
||||||
@@ -139,10 +164,10 @@ describe('Contact Page', () => {
|
|||||||
|
|
||||||
const expectedParams = {
|
const expectedParams = {
|
||||||
name: '<script>alert(1)</script>',
|
name: '<script>alert(1)</script>',
|
||||||
email: 'john@example.com',
|
email: 'john@valid-email.com',
|
||||||
title: '<b>Bold</b>',
|
title: '<b>Bold</b>',
|
||||||
message: '"Quotes"',
|
message: '"Quotes"',
|
||||||
reply_to: 'john@example.com',
|
reply_to: 'john@valid-email.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check first call
|
// Check first call
|
||||||
@@ -161,7 +186,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 +203,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 +229,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@valid-email.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' }));
|
||||||
|
|||||||
Reference in New Issue
Block a user