🛡️ Sentinel: [HIGH] Strict Email Validation to Prevent XSS #28
@@ -7,3 +7,13 @@
|
||||
**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.
|
||||
**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.
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "../i18n";
|
||||
import { useRateLimit } from "../hooks";
|
||||
import { config } from "../config";
|
||||
import { Button, Input, Textarea } from "../components/ui";
|
||||
import { sanitizeInput } from "../utils/security";
|
||||
import { sanitizeInput, isValidEmail } from "../utils/security";
|
||||
import styles from "./Contact.module.css";
|
||||
|
||||
const NAME_MAX_LENGTH = 100;
|
||||
@@ -53,7 +53,7 @@ export function Contact() {
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = "Required";
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
} else if (!isValidEmail(formData.email)) {
|
||||
newErrors.email = "Invalid email";
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ describe('Contact Page', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
document.body.innerHTML = '';
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('submits the form with correct parameters', async () => {
|
||||
@@ -170,4 +171,26 @@ describe('Contact Page', () => {
|
||||
// EmailJS should NOT be called
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
56
src/utils/security.test.ts
Normal file
56
src/utils/security.test.ts
Normal 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('<script>');
|
||||
expect(sanitizeInput('foo & bar')).toBe('foo & bar');
|
||||
expect(sanitizeInput('"quotes"')).toBe('"quotes"');
|
||||
expect(sanitizeInput("'single quotes'")).toBe(''single quotes'');
|
||||
expect(sanitizeInput('>')).toBe('>');
|
||||
});
|
||||
|
||||
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 = '<script>alert("XSS")</script>';
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,3 +16,21 @@ export function sanitizeInput(input: string): string {
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user