diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47e86e8..ad3814d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,9 @@ name: CI on: [push, pull_request] +permissions: + contents: read + jobs: check: runs-on: ubuntu-latest diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 1c1cd30..ee6d1ae 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -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'); } diff --git a/app/api/auth/verify-email/resend/route.ts b/app/api/auth/verify-email/resend/route.ts index d4f66fc..88d457f 100644 --- a/app/api/auth/verify-email/resend/route.ts +++ b/app/api/auth/verify-email/resend/route.ts @@ -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'); } diff --git a/app/api/projects/[projectId]/members/route.ts b/app/api/projects/[projectId]/members/route.ts index d2889de..c5bc532 100644 --- a/app/api/projects/[projectId]/members/route.ts +++ b/app/api/projects/[projectId]/members/route.ts @@ -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'); } diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index ca33ada..cf889f7 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -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, diff --git a/app/api/workspaces/[workspaceId]/members/route.ts b/app/api/workspaces/[workspaceId]/members/route.ts index 69c9187..748ce1e 100644 --- a/app/api/workspaces/[workspaceId]/members/route.ts +++ b/app/api/workspaces/[workspaceId]/members/route.ts @@ -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'); } diff --git a/lib/email-validation.ts b/lib/email-validation.ts new file mode 100644 index 0000000..2b8271f --- /dev/null +++ b/lib/email-validation.ts @@ -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); +}