🛡️ Sentinel: Enhance email security with strict validation and sanitization #39

Merged
ragusa-it merged 2 commits from sentinel-email-security-enhancement-1361969690950207643 into main 2026-01-30 05:00:02 +00:00
4 changed files with 64 additions and 7 deletions

View File

@@ -17,3 +17,13 @@
**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.
## 2026-02-14 - Strict Email Regex vs RFC Compliance
**Vulnerability:** Standard email validation often allows characters like quotes (`"`) or backticks (`` ` ``) which can be vectors for XSS in HTML attribute contexts.
**Learning:** While RFCs allow quoted local parts (e.g., `"user"@example.com`), these are rare in practice and pose significant security risks in web applications. It is often safer to explicitly reject them. However, apostrophes (`'`) are common in names (O'Connor) and should be allowed, relying on sanitization (Defense in Depth) rather than validation to handle them safely.
**Prevention:** Use a hybrid approach: Strict validation (reject quotes/backticks) for high-risk characters, coupled with output sanitization (`sanitizeInput`) for characters that must be allowed but are still risky (apostrophes).
## 2026-02-14 - Blocking Disposable Domains
**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.

View File

@@ -93,7 +93,7 @@ export function Contact() {
try {
const templateParams = {
name: sanitizeInput(formData.name),
email: formData.email, // Email doesn't typically need HTML sanitization if validated by regex, but good practice to handle it if used in HTML context.
email: sanitizeInput(formData.email),
title: sanitizeInput(formData.subject),
message: sanitizeInput(formData.message),
reply_to: formData.email,

View File

@@ -29,14 +29,14 @@ describe('Security Utils', () => {
describe('isValidEmail', () => {
it('accepts valid email addresses', () => {
expect(isValidEmail('test@example.com')).toBe(true);
expect(isValidEmail('test@valid-domain.com')).toBe(true);
expect(isValidEmail('john.doe@sub.domain.co.uk')).toBe(true);
expect(isValidEmail('user+tag@example.com')).toBe(true);
expect(isValidEmail('user+tag@valid-domain.com')).toBe(true);
});
it('rejects invalid email formats', () => {
expect(isValidEmail('plainaddress')).toBe(false);
expect(isValidEmail('@example.com')).toBe(false);
expect(isValidEmail('@valid-domain.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)
@@ -48,9 +48,37 @@ describe('Security Utils', () => {
expect(isValidEmail('user<name>@example.com')).toBe(false);
});
it('rejects emails with double quotes and backticks', () => {
expect(isValidEmail('user"name@example.com')).toBe(false);
expect(isValidEmail('user`name@example.com')).toBe(false);
});
it('accepts emails with apostrophes', () => {
expect(isValidEmail("user'name@valid-domain.com")).toBe(true);
expect(isValidEmail("o'connor@valid-domain.com")).toBe(true);
});
it('rejects emails with whitespace', () => {
expect(isValidEmail('user @example.com')).toBe(false);
expect(isValidEmail('user@ example.com')).toBe(false);
});
it('rejects short TLDs (less than 2 chars)', () => {
expect(isValidEmail('user@domain.c')).toBe(false);
expect(isValidEmail('user@domain.1')).toBe(false);
});
it('rejects blocked/disposable domains', () => {
expect(isValidEmail('user@example.com')).toBe(false);
expect(isValidEmail('test@test.com')).toBe(false);
expect(isValidEmail('spam@mailinator.com')).toBe(false);
expect(isValidEmail('bot@yopmail.com')).toBe(false);
expect(isValidEmail('temp@temp-mail.org')).toBe(false);
});
it('rejects blocked domains regardless of case', () => {
expect(isValidEmail('user@EXAMPLE.COM')).toBe(false);
expect(isValidEmail('user@MaIlInAtOr.CoM')).toBe(false);
});
});
});

View File

@@ -17,9 +17,22 @@ export function sanitizeInput(input: string): string {
.replace(/'/g, "&#039;");
}
// Common disposable email providers and invalid domains
const BLOCKED_DOMAINS = new Set([
"example.com",
"test.com",
"mailinator.com",
"yopmail.com",
"temp-mail.org",
"guerrillamail.com",
"10minutemail.com",
"trashmail.com",
]);
/**
* Validates an email address format securely.
* Rejects inputs containing dangerous characters like <, >, or whitespace.
* Also validates domain validity (blocked domains, TLD length).
*
* @param email - The email string to validate.
* @returns True if the email is valid and safe, false otherwise.
@@ -30,7 +43,13 @@ export function isValidEmail(email: string): boolean {
// @ : Literal @
// [^\s@<>]+ : Domain part - no whitespace, @, <, or >
// \. : Literal .
// [^\s@<>]+ : TLD part - no whitespace, @, <, or >
const emailRegex = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
return emailRegex.test(email);
// [^\s@<>]+ : TLD part - no whitespace, @, <, or > (min 2 chars)
const emailRegex = /^[^\s@<>,"`]+@[^\s@<>,"`]+\.[^\s@<>,"`]{2,}$/;
if (!emailRegex.test(email)) {
return false;
}
// Check against blocked domains
const domain = email.split("@")[1].toLowerCase();
return !BLOCKED_DOMAINS.has(domain);
}