Merge pull request #28 from ragusa-it/sentinel/strict-email-validation-13033948493013017418
🛡️ Sentinel: [HIGH] Strict Email Validation to Prevent XSS
This commit was merged in pull request #28.
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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, """)
|
||||||
.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