🛡️ Sentinel: [HIGH] Implement strict email validation

- Implemented `isValidEmail` utility with strict regex validation (rejects `<` and `>`) to prevent XSS vectors.
- Updated `Contact.tsx` to use `isValidEmail` instead of weak regex.
- Added comprehensive tests for `isValidEmail` in `src/utils/security.test.ts`.
- Fixed flaky test in `src/pages/__tests__/Contact.test.tsx` by clearing `localStorage` in `afterEach`.
- Added test case for invalid email submission.
- Documented findings in `.jules/sentinel.md`.

Co-authored-by: ragusa-it <196988693+ragusa-it@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot]
2026-01-27 01:56:08 +00:00
parent 9223331ee9
commit 57f7c5667f
5 changed files with 109 additions and 2 deletions

View File

@@ -7,3 +7,13 @@
**Vulnerability:** Contact forms using client-side services (like EmailJS) without backend middleware are vulnerable to spam and quota exhaustion. **Vulnerability:** Contact forms using client-side services (like EmailJS) without backend middleware are vulnerable to spam and quota exhaustion.
**Learning:** While true rate limiting requires a backend, client-side throttling via `localStorage` provides a necessary friction layer for legitimate users and simple bots, protecting external service quotas. **Learning:** While true rate limiting requires a backend, client-side throttling via `localStorage` provides a necessary friction layer for legitimate users and simple bots, protecting external service quotas.
**Prevention:** Implement reusable rate-limit hooks for all public-facing form submissions in static/serverless applications. **Prevention:** Implement reusable rate-limit hooks for all public-facing form submissions in static/serverless applications.
## 2026-02-13 - State Leakage in Tests masking Security Failures
**Vulnerability:** Flaky tests caused by `localStorage` state leakage (e.g. rate limits persisting between tests) can prevent security features from being properly verified, leading to false negatives or untested paths.
**Learning:** Global state like `localStorage` must be explicitly cleared in `afterEach` blocks in test environments (jsdom). Failing to do so can cause subsequent tests to fail or behave unpredictably, especially for rate-limiting logic.
**Prevention:** Always include `localStorage.clear()` in `afterEach` (or `beforeEach`) when testing components that rely on local storage.
## 2026-02-13 - Strict Email Validation vs HTML5 Validation
**Vulnerability:** Standard email regexes and HTML5 validation are often too permissive, allowing XSS vectors (like `<script>`) in email fields if not properly sanitized/rejected.
**Learning:** While HTML5 browsers block some invalid emails, relying solely on them is insufficient for defense-in-depth. Application-level validation should explicitly reject dangerous characters (`<`, `>`) to prevent stored XSS or injection if the data is processed by less-secure backends.
**Prevention:** Implement strict, reusable validation functions (`isValidEmail`) that reject XSS vectors, and ensure tests verify this logic by bypassing browser validation if necessary.

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "../i18n";
import { useRateLimit } from "../hooks"; import { useRateLimit } from "../hooks";
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 { sanitizeInput, isValidEmail } from "../utils/security";
import styles from "./Contact.module.css"; import styles from "./Contact.module.css";
const NAME_MAX_LENGTH = 100; const NAME_MAX_LENGTH = 100;
@@ -53,7 +53,7 @@ export function Contact() {
if (!formData.email.trim()) { if (!formData.email.trim()) {
newErrors.email = "Required"; newErrors.email = "Required";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { } else if (!isValidEmail(formData.email)) {
newErrors.email = "Invalid email"; newErrors.email = "Invalid email";
} }

View File

@@ -63,6 +63,7 @@ describe('Contact Page', () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
document.body.innerHTML = ''; document.body.innerHTML = '';
localStorage.clear();
}); });
it('submits the form with correct parameters', async () => { it('submits the form with correct parameters', async () => {
@@ -170,4 +171,26 @@ describe('Contact Page', () => {
// EmailJS should NOT be called // EmailJS should NOT be called
expect(emailjs.send).not.toHaveBeenCalled(); expect(emailjs.send).not.toHaveBeenCalled();
}); });
it('shows error when email contains invalid characters', async () => {
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' } });
// 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
const form = container.querySelector('form');
if (form) fireEvent.submit(form);
// Validation error should appear
const errorMessage = await screen.findByText('Invalid email');
expect(errorMessage).toBeTruthy();
// EmailJS should NOT be called
expect(emailjs.send).not.toHaveBeenCalled();
});
}); });

View File

@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { sanitizeInput, isValidEmail } from './security';
describe('Security Utils', () => {
describe('sanitizeInput', () => {
it('escapes special HTML characters', () => {
expect(sanitizeInput('<script>')).toBe('&lt;script&gt;');
expect(sanitizeInput('foo & bar')).toBe('foo &amp; bar');
expect(sanitizeInput('"quotes"')).toBe('&quot;quotes&quot;');
expect(sanitizeInput("'single quotes'")).toBe('&#039;single quotes&#039;');
expect(sanitizeInput('>')).toBe('&gt;');
});
it('returns non-string input as is', () => {
// @ts-ignore
expect(sanitizeInput(123)).toBe(123);
// @ts-ignore
expect(sanitizeInput(null)).toBe(null);
});
it('handles mixed content correctly', () => {
const input = '<script>alert("XSS")</script>';
const expected = '&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;';
expect(sanitizeInput(input)).toBe(expected);
});
});
describe('isValidEmail', () => {
it('accepts valid email addresses', () => {
expect(isValidEmail('test@example.com')).toBe(true);
expect(isValidEmail('john.doe@sub.domain.co.uk')).toBe(true);
expect(isValidEmail('user+tag@example.com')).toBe(true);
});
it('rejects invalid email formats', () => {
expect(isValidEmail('plainaddress')).toBe(false);
expect(isValidEmail('@example.com')).toBe(false);
expect(isValidEmail('user@')).toBe(false);
expect(isValidEmail('user@.com')).toBe(false);
expect(isValidEmail('user@com')).toBe(false); // Missing dot in domain part (simple regex might allow, but strict one requires dot)
});
it('rejects emails with dangerous characters (<, >)', () => {
expect(isValidEmail('<script>@example.com')).toBe(false);
expect(isValidEmail('user@<script>.com')).toBe(false);
expect(isValidEmail('user<name>@example.com')).toBe(false);
});
it('rejects emails with whitespace', () => {
expect(isValidEmail('user @example.com')).toBe(false);
expect(isValidEmail('user@ example.com')).toBe(false);
});
});
});

View File

@@ -16,3 +16,21 @@ export function sanitizeInput(input: string): string {
.replace(/"/g, "&quot;") .replace(/"/g, "&quot;")
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
} }
/**
* Validates an email address format securely.
* Rejects inputs containing dangerous characters like <, >, or whitespace.
*
* @param email - The email string to validate.
* @returns True if the email is valid and safe, false otherwise.
*/
export function isValidEmail(email: string): boolean {
// Basic format check + rejection of XSS vectors (<, >)
// [^\s@<>]+ : Local part - no whitespace, @, <, or >
// @ : Literal @
// [^\s@<>]+ : Domain part - no whitespace, @, <, or >
// \. : Literal .
// [^\s@<>]+ : TLD part - no whitespace, @, <, or >
const emailRegex = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
return emailRegex.test(email);
}