fix: harden email validation and CI permissions

This commit is contained in:
yusufipk
2026-06-14 16:59:09 +02:00
parent 56fb7403cf
commit 9613c4f2c6
7 changed files with 49 additions and 21 deletions
+3
View File
@@ -2,6 +2,9 @@ name: CI
on: [push, pull_request]
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
+3 -3
View File
@@ -16,6 +16,7 @@ import {
isEmailVerificationEnabled,
sendVerificationEmail,
} from '@/lib/email-verification';
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
export async function POST(request: NextRequest) {
try {
@@ -39,11 +40,10 @@ export async function POST(request: NextRequest) {
if (!email || typeof email !== 'string') {
return apiErrors.badRequest('Email is required');
}
const normalizedEmail = email.toLowerCase().trim();
const normalizedEmail = normalizeEmail(email);
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(normalizedEmail)) {
if (!isValidEmailAddress(normalizedEmail)) {
return apiErrors.validationError('Invalid email format');
}
+4 -4
View File
@@ -8,6 +8,7 @@ import {
sendVerificationEmail,
} from '@/lib/email-verification';
import { logError } from '@/lib/logger';
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
export async function POST(request: NextRequest) {
try {
@@ -28,14 +29,13 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const { email } = body;
if (!email || typeof email !== 'string' || email.length > 254 || !email.includes('@')) {
if (!email || typeof email !== 'string') {
return apiErrors.badRequest('Valid email is required');
}
const normalizedEmail = email.toLowerCase().trim();
const normalizedEmail = normalizeEmail(email);
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(normalizedEmail)) {
if (!isValidEmailAddress(normalizedEmail)) {
return apiErrors.badRequest('Valid email is required');
}
@@ -10,6 +10,7 @@ import {
} from '@/lib/invitations';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger';
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
type RouteParams = { params: Promise<{ projectId: string }> };
@@ -126,9 +127,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Email is required');
}
const normalizedEmail = email.toLowerCase().trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(normalizedEmail)) {
const normalizedEmail = normalizeEmail(email);
if (!isValidEmailAddress(normalizedEmail)) {
return apiErrors.validationError('Invalid email format');
}
@@ -21,6 +21,7 @@ import {
import { validateAnnotationStrokes } from '@/lib/validation';
import { logError } from '@/lib/logger';
import { reserveStorageQuota, releaseStorageReservation } from '@/lib/storage-quota';
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
type RouteParams = { params: Promise<{ versionId: string }> };
const SAFE_IMAGE_PATH =
@@ -328,14 +329,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
if (guestName !== undefined && guestName !== null && String(guestName).length > 100) {
return apiErrors.badRequest('Guest name must be 100 characters or fewer');
}
let normalizedGuestEmail: string | null = null;
if (guestEmail !== undefined && guestEmail !== null) {
const emailStr = String(guestEmail);
if (emailStr.length > 254) {
return apiErrors.badRequest('Guest email must be 254 characters or fewer');
}
// RFC 5321 / HTML5 email pattern — simple but sufficient for a stored-value guard
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRe.test(emailStr)) {
normalizedGuestEmail = normalizeEmail(String(guestEmail));
if (!isValidEmailAddress(normalizedGuestEmail)) {
return apiErrors.badRequest('Guest email must be a valid email address');
}
}
@@ -444,7 +441,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
annotationData: serializedAnnotationData,
authorId: session?.user?.id || null,
guestName: isGuest ? guestName : null,
guestEmail: isGuest ? guestEmail : null,
guestEmail: isGuest ? normalizedGuestEmail : null,
guestIdentityId: isGuest ? (guestIdentity?.identityId ?? null) : null,
tagId: tagId || null,
versionId,
@@ -10,6 +10,7 @@ import {
} from '@/lib/invitations';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger';
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
type RouteParams = { params: Promise<{ workspaceId: string }> };
@@ -168,9 +169,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Email is required');
}
const normalizedEmail = email.toLowerCase().trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(normalizedEmail)) {
const normalizedEmail = normalizeEmail(email);
if (!isValidEmailAddress(normalizedEmail)) {
return apiErrors.validationError('Invalid email format');
}
+28
View File
@@ -0,0 +1,28 @@
const MAX_EMAIL_LENGTH = 254;
const MAX_EMAIL_LOCAL_LENGTH = 64;
const MAX_EMAIL_DOMAIN_LABEL_LENGTH = 63;
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
export function isValidEmailAddress(email: string): boolean {
if (email.length < 3 || email.length > MAX_EMAIL_LENGTH) return false;
const atIndex = email.indexOf('@');
if (atIndex <= 0 || atIndex !== email.lastIndexOf('@')) return false;
const local = email.slice(0, atIndex);
const domain = email.slice(atIndex + 1);
if (local.length > MAX_EMAIL_LOCAL_LENGTH || !domain.includes('.')) return false;
for (const char of email) {
const code = char.charCodeAt(0);
if (code <= 32 || code === 127) return false;
}
const labels = domain.split('.');
if (labels.length < 2) return false;
return labels.every((label) => label.length > 0 && label.length <= MAX_EMAIL_DOMAIN_LABEL_LENGTH);
}