diff --git a/apps/web/src/app/api/contact/__tests__/route.test.ts b/apps/web/src/app/api/contact/__tests__/route.test.ts new file mode 100644 index 000000000..5d8a9f42c --- /dev/null +++ b/apps/web/src/app/api/contact/__tests__/route.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { POST } from '../route'; + +/** + * /api/contact Endpoint Contract Tests + * + * This endpoint handles public contact form submissions. + * + * Contract: + * Request: POST with name, email, subject, message + * Response: + * 201: { message: string } - Submission saved + * 400: { error: string } - Validation failed + * 413: { error: string } - Payload too large + * 429: { error: string } - Rate limit exceeded + * 500: { error: string } - Internal error + * + * Security Properties: + * - No authentication required (public endpoint) + * - Rate limited: 10 requests/minute per IP + * - Payload size cap: 5KB + * - Strict schema validation + */ + +vi.mock('@pagespace/db', () => ({ + db: { + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockResolvedValue(undefined), + }), + }, + contactSubmissions: {}, +})); + +vi.mock('@pagespace/lib/server', () => ({ + loggers: { + api: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + }, +})); + +vi.mock('@pagespace/lib/security', () => ({ + checkDistributedRateLimit: vi.fn(), + DISTRIBUTED_RATE_LIMITS: { + CONTACT_FORM: { maxAttempts: 10, windowMs: 60000 }, + }, +})); + +vi.mock('@/lib/auth/auth-helpers', () => ({ + getClientIP: vi.fn().mockReturnValue('127.0.0.1'), +})); + +import { db } from '@pagespace/db'; +import { loggers } from '@pagespace/lib/server'; +import { checkDistributedRateLimit } from '@pagespace/lib/security'; + +const validPayload = { + name: 'John Doe', + email: 'john@example.com', + subject: 'Hello there', + message: 'This is a valid contact message with enough characters.', +}; + +const createRequest = (body: object, headers?: Record) => { + const bodyStr = JSON.stringify(body); + return new Request('http://localhost/api/contact', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': String(Buffer.byteLength(bodyStr)), + ...headers, + }, + body: bodyStr, + }); +}; + +describe('/api/contact', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkDistributedRateLimit).mockResolvedValue({ allowed: true }); + vi.mocked(db.insert).mockReturnValue({ + values: vi.fn().mockResolvedValue(undefined), + } as never); + }); + + describe('successful submission', () => { + it('POST_withValidPayload_returns201', async () => { + const request = createRequest(validPayload); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.message).toContain('successfully'); + }); + + it('POST_withValidPayload_insertsToDatabase', async () => { + const request = createRequest(validPayload); + await POST(request); + + expect(db.insert).toHaveBeenCalled(); + const valuesMock = (vi.mocked(db.insert).mock.results[0].value as { values: ReturnType }).values; + expect(valuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'John Doe', + email: 'john@example.com', + subject: 'Hello there', + }) + ); + }); + + it('POST_withValidPayload_logsSubmission', async () => { + const request = createRequest(validPayload); + await POST(request); + + expect(loggers.api.info).toHaveBeenCalledWith( + 'Contact submission received', + expect.objectContaining({ ip: '127.0.0.1' }) + ); + }); + }); + + describe('rate limiting (429)', () => { + it('POST_whenRateLimitExceeded_returns429', async () => { + vi.mocked(checkDistributedRateLimit).mockResolvedValue({ + allowed: false, + retryAfter: 45, + }); + + const request = createRequest(validPayload); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(429); + expect(body.error).toContain('Too many'); + expect(response.headers.get('Retry-After')).toBe('45'); + }); + + it('POST_whenRateLimitExceeded_doesNotInsert', async () => { + vi.mocked(checkDistributedRateLimit).mockResolvedValue({ + allowed: false, + retryAfter: 45, + }); + + const request = createRequest(validPayload); + await POST(request); + + expect(db.insert).not.toHaveBeenCalled(); + }); + + it('POST_whenRateLimitExceeded_logsWarning', async () => { + vi.mocked(checkDistributedRateLimit).mockResolvedValue({ + allowed: false, + retryAfter: 45, + }); + + const request = createRequest(validPayload); + await POST(request); + + expect(loggers.api.warn).toHaveBeenCalledWith( + 'Contact form rate limit exceeded', + expect.objectContaining({ ip: '127.0.0.1' }) + ); + }); + }); + + describe('payload size enforcement (413)', () => { + it('POST_withOversizedPayload_returns413', async () => { + const oversized = { ...validPayload, message: 'x'.repeat(6000) }; + const request = createRequest(oversized); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(413); + expect(body.error).toContain('Payload too large'); + }); + + it('POST_withOversizedContentLength_returns413', async () => { + const request = createRequest(validPayload, { 'Content-Length': '10000' }); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(413); + expect(body.error).toContain('Payload too large'); + }); + }); + + describe('schema validation (400)', () => { + it('POST_withMissingName_returns400', async () => { + const { name: _, ...noName } = validPayload; + const request = createRequest(noName); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe('Validation failed'); + }); + + it('POST_withInvalidEmail_returns400', async () => { + const request = createRequest({ ...validPayload, email: 'not-an-email' }); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe('Validation failed'); + }); + + it('POST_withEmptySubject_returns400', async () => { + const request = createRequest({ ...validPayload, subject: '' }); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe('Validation failed'); + }); + + it('POST_withMessageTooShort_returns400', async () => { + const request = createRequest({ ...validPayload, message: 'Hi' }); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe('Validation failed'); + }); + + it('POST_withMessageTooLong_returns400', async () => { + const request = createRequest({ ...validPayload, message: 'x'.repeat(2001) }); + const response = await POST(request); + + expect(response.status).toBe(400); + }); + + it('POST_withUnexpectedFields_stripsExtras', async () => { + const request = createRequest({ + ...validPayload, + extraField: 'should be ignored', + anotherExtra: 123, + }); + const response = await POST(request); + + expect(response.status).toBe(201); + }); + + it('POST_withInvalidJSON_returns400', async () => { + const request = new Request('http://localhost/api/contact', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': '12', + }, + body: 'not valid json', + }); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe('Invalid JSON'); + }); + + it('POST_withNameTooLong_returns400', async () => { + const request = createRequest({ ...validPayload, name: 'x'.repeat(101) }); + const response = await POST(request); + + expect(response.status).toBe(400); + }); + }); + + describe('error handling (500)', () => { + it('POST_whenDatabaseThrows_returns500WithGenericError', async () => { + vi.mocked(db.insert).mockReturnValue({ + values: vi.fn().mockRejectedValue(new Error('DB connection failed')), + } as never); + + const request = createRequest(validPayload); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.error).toBe('An unexpected error occurred. Please try again later.'); + expect(body.error).not.toContain('DB connection'); + }); + + it('POST_whenDatabaseThrows_logsError', async () => { + vi.mocked(db.insert).mockReturnValue({ + values: vi.fn().mockRejectedValue(new Error('DB connection failed')), + } as never); + + const request = createRequest(validPayload); + await POST(request); + + expect(loggers.api.error).toHaveBeenCalledWith( + 'Contact form error', + expect.any(Error) + ); + }); + }); +}); diff --git a/apps/web/src/app/api/contact/route.ts b/apps/web/src/app/api/contact/route.ts new file mode 100644 index 000000000..b9a050083 --- /dev/null +++ b/apps/web/src/app/api/contact/route.ts @@ -0,0 +1,114 @@ +/** + * Public contact form endpoint + * No authentication required — hardened with zero-trust controls: + * - Rate limited: 10 requests/minute per IP + * - Payload size cap: 5KB + * - Strict schema validation + */ + +import { z } from 'zod/v4'; +import { db, contactSubmissions } from '@pagespace/db'; +import { loggers } from '@pagespace/lib/server'; +import { checkDistributedRateLimit, DISTRIBUTED_RATE_LIMITS } from '@pagespace/lib/security'; +import { getClientIP } from '@/lib/auth/auth-helpers'; + +const MAX_PAYLOAD_BYTES = 5 * 1024; // 5KB + +const contactSchema = z.object({ + name: z.string().min(1, 'Name is required').max(100), + email: z.string().email('Valid email is required').max(254), + subject: z.string().min(1, 'Subject is required').max(200), + message: z.string().min(10, 'Message must be at least 10 characters').max(2000), +}); + +export async function POST(request: Request) { + try { + const ip = getClientIP(request); + + // Rate limit by IP + const rateLimitResult = await checkDistributedRateLimit( + `contact:ip:${ip}`, + DISTRIBUTED_RATE_LIMITS.CONTACT_FORM + ); + + if (!rateLimitResult.allowed) { + loggers.api.warn('Contact form rate limit exceeded', { ip }); + return Response.json( + { error: 'Too many submissions. Please try again later.' }, + { + status: 429, + headers: { 'Retry-After': String(rateLimitResult.retryAfter || 60) }, + } + ); + } + + // Check payload size via Content-Length header + const contentLength = parseInt(request.headers.get('content-length') || '0', 10); + if (contentLength > MAX_PAYLOAD_BYTES) { + return Response.json( + { error: 'Payload too large' }, + { status: 413 } + ); + } + + // Read body as text first to verify actual byte size + const rawBody = await request.text(); + if (Buffer.byteLength(rawBody, 'utf-8') > MAX_PAYLOAD_BYTES) { + return Response.json( + { error: 'Payload too large' }, + { status: 413 } + ); + } + + // Parse JSON + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return Response.json( + { error: 'Invalid JSON' }, + { status: 400 } + ); + } + + // Schema validation + const validation = contactSchema.safeParse(parsed); + if (!validation.success) { + return Response.json( + { + error: 'Validation failed', + details: validation.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { name, email, subject, message } = validation.data; + + // Store in database + await db.insert(contactSubmissions).values({ + name: name.trim(), + email: email.trim(), + subject: subject.trim(), + message: message.trim(), + }); + + const trimmedEmail = email.trim(); + const maskedEmail = trimmedEmail.replace(/(.{2}).*(@.*)/, '$1***$2'); + loggers.api.info('Contact submission received', { + ip, + email: maskedEmail, + }); + + return Response.json( + { message: 'Message sent successfully. We\'ll get back to you soon!' }, + { status: 201 } + ); + } catch (error) { + loggers.api.error('Contact form error', error as Error); + return Response.json( + { error: 'An unexpected error occurred. Please try again later.' }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/track/__tests__/route.test.ts b/apps/web/src/app/api/track/__tests__/route.test.ts new file mode 100644 index 000000000..5e14f2786 --- /dev/null +++ b/apps/web/src/app/api/track/__tests__/route.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { POST, PUT } from '../route'; + +/** + * /api/track Endpoint Contract Tests + * + * This endpoint handles client-side analytics events (fire-and-forget). + * + * Contract: + * Request: POST with tracking event payload + * Response: + * 200: { ok: true } - Event tracked successfully + * 400: { error: string } - Invalid schema + * 413: { error: string } - Payload too large + * 429: { error: string } - Rate limit exceeded + * + * Security Properties: + * - No authentication required (public endpoint) + * - Rate limited: 100 requests/minute per IP + * - Payload size cap: 10KB + * - Schema validation: only known event types accepted + */ + +vi.mock('@pagespace/lib/activity-tracker', () => ({ + trackActivity: vi.fn(), + trackFeature: vi.fn(), + trackError: vi.fn(), +})); + +vi.mock('@pagespace/lib/security', () => ({ + checkDistributedRateLimit: vi.fn(), + DISTRIBUTED_RATE_LIMITS: { + TRACKING: { maxAttempts: 100, windowMs: 60000 }, + }, +})); + +vi.mock('@/lib/auth', () => ({ + authenticateRequestWithOptions: vi.fn(), + isAuthError: vi.fn().mockReturnValue(true), +})); + +vi.mock('@/lib/auth/auth-helpers', () => ({ + getClientIP: vi.fn().mockReturnValue('127.0.0.1'), +})); + +import { checkDistributedRateLimit } from '@pagespace/lib/security'; +import { trackActivity, trackFeature, trackError } from '@pagespace/lib/activity-tracker'; + +const createRequest = (body: object, headers?: Record) => { + const bodyStr = JSON.stringify(body); + return new Request('http://localhost/api/track', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': String(Buffer.byteLength(bodyStr)), + ...headers, + }, + body: bodyStr, + }); +}; + +describe('/api/track', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkDistributedRateLimit).mockResolvedValue({ allowed: true }); + }); + + describe('successful tracking', () => { + it('POST_withValidPageView_returns200', async () => { + const request = createRequest({ event: 'page_view', data: { path: '/home' } }); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.ok).toBe(true); + }); + + it('POST_withValidPageView_callsTrackActivity', async () => { + const request = createRequest({ event: 'page_view', data: { path: '/home' } }); + await POST(request); + + expect(trackActivity).toHaveBeenCalledWith( + undefined, + 'page_view', + expect.objectContaining({ ip: '127.0.0.1' }) + ); + }); + + it('POST_withFeatureUsed_callsTrackFeature', async () => { + const request = createRequest({ event: 'feature_used', data: { feature: 'dark-mode' } }); + await POST(request); + + expect(trackFeature).toHaveBeenCalledWith( + undefined, + 'dark-mode', + expect.objectContaining({ feature: 'dark-mode' }) + ); + }); + + it('POST_withClientError_callsTrackError', async () => { + const request = createRequest({ + event: 'client_error', + data: { type: 'js', message: 'Uncaught TypeError' }, + }); + await POST(request); + + expect(trackError).toHaveBeenCalledWith( + undefined, + 'js', + 'Uncaught TypeError', + expect.any(Object) + ); + }); + + it('POST_withClickEvent_returns200', async () => { + const request = createRequest({ event: 'click', data: { label: 'nav-button' } }); + const response = await POST(request); + + expect(response.status).toBe(200); + expect(trackActivity).toHaveBeenCalledWith( + undefined, + 'ui_click', + expect.any(Object) + ); + }); + + it('POST_withEventOnly_returns200', async () => { + const request = createRequest({ event: 'search' }); + const response = await POST(request); + + expect(response.status).toBe(200); + expect(trackActivity).toHaveBeenCalled(); + }); + }); + + describe('rate limiting (429)', () => { + it('POST_whenRateLimitExceeded_returns429', async () => { + vi.mocked(checkDistributedRateLimit).mockResolvedValue({ + allowed: false, + retryAfter: 30, + }); + + const request = createRequest({ event: 'page_view' }); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(429); + expect(body.error).toContain('Too many'); + expect(response.headers.get('Retry-After')).toBe('30'); + }); + + it('POST_whenRateLimitExceeded_doesNotTrack', async () => { + vi.mocked(checkDistributedRateLimit).mockResolvedValue({ + allowed: false, + retryAfter: 30, + }); + + const request = createRequest({ event: 'page_view' }); + await POST(request); + + expect(trackActivity).not.toHaveBeenCalled(); + }); + }); + + describe('payload size enforcement (413)', () => { + it('POST_withOversizedPayload_returns413', async () => { + const largeData = { event: 'page_view' as const, data: { path: 'x'.repeat(15000) } }; + const request = createRequest(largeData); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(413); + expect(body.error).toContain('Payload too large'); + }); + + it('POST_withOversizedContentLength_returns413', async () => { + const request = createRequest( + { event: 'page_view' }, + { 'Content-Length': '20000' } + ); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(413); + expect(body.error).toContain('Payload too large'); + }); + }); + + describe('schema validation (400)', () => { + it('POST_withUnknownEvent_returns400', async () => { + const bodyStr = JSON.stringify({ event: 'unknown_event_type' }); + const request = new Request('http://localhost/api/track', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': String(Buffer.byteLength(bodyStr)), + }, + body: bodyStr, + }); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toContain('Invalid tracking payload'); + }); + + it('POST_withMissingEvent_returns400', async () => { + const bodyStr = JSON.stringify({ data: { path: '/home' } }); + const request = new Request('http://localhost/api/track', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': String(Buffer.byteLength(bodyStr)), + }, + body: bodyStr, + }); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toContain('Invalid tracking payload'); + }); + + it('POST_withInvalidJSON_returnsSilentSuccess', async () => { + const request = new Request('http://localhost/api/track', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': '12', + }, + body: 'not valid json', + }); + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.ok).toBe(true); + }); + }); + + describe('PUT (beacon API)', () => { + it('PUT_withValidPayload_returns200', async () => { + const bodyStr = JSON.stringify({ event: 'page_view', data: { path: '/exit' } }); + const request = new Request('http://localhost/api/track', { + method: 'PUT', + headers: { + 'Content-Type': 'text/plain', + 'Content-Length': String(Buffer.byteLength(bodyStr)), + }, + body: bodyStr, + }); + const response = await PUT(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.ok).toBe(true); + }); + + it('PUT_withOversizedPayload_returns413', async () => { + const bodyStr = JSON.stringify({ event: 'page_view', data: { path: 'x'.repeat(15000) } }); + const request = new Request('http://localhost/api/track', { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: bodyStr, + }); + const response = await PUT(request); + const body = await response.json(); + + expect(response.status).toBe(413); + expect(body.error).toContain('Payload too large'); + }); + }); +}); diff --git a/apps/web/src/app/api/track/route.ts b/apps/web/src/app/api/track/route.ts index c4ccb4191..7ce2410a0 100644 --- a/apps/web/src/app/api/track/route.ts +++ b/apps/web/src/app/api/track/route.ts @@ -1,49 +1,120 @@ /** * Simple tracking endpoint for client-side events - * Fire-and-forget, always returns success + * Fire-and-forget, always returns success for valid requests + * + * Zero-trust policy: + * - Rate limited: 100 requests/minute per IP + * - Payload size cap: 10KB + * - Schema validation: only known event types and fields accepted */ -import { NextResponse } from 'next/server'; +import { z } from 'zod/v4'; import { trackActivity, trackFeature, trackError } from '@pagespace/lib/activity-tracker'; +import { checkDistributedRateLimit, DISTRIBUTED_RATE_LIMITS } from '@pagespace/lib/security'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { getClientIP } from '@/lib/auth/auth-helpers'; -// CSRF protection intentionally disabled for analytics tracking: -// 1. Beacon API (navigator.sendBeacon) cannot set custom headers including CSRF tokens -// 2. This endpoint only writes to analytics logs, not user data -// 3. Auth is optional - allows tracking even for unauthenticated users -// 4. All events are fire-and-forget with no user-visible effects const AUTH_OPTIONS = { allow: ['session'] as const, requireCSRF: false }; +const MAX_PAYLOAD_BYTES = 10 * 1024; // 10KB + +const VALID_EVENTS = [ + 'page_view', + 'feature_used', + 'user_action', + 'search', + 'click', + 'client_error', + 'timing', +] as const; + +const trackingSchema = z.object({ + event: z.enum(VALID_EVENTS), + data: z.object({ + feature: z.string().max(200).optional(), + action: z.string().max(200).optional(), + resource: z.string().max(200).optional(), + resourceId: z.string().max(200).optional(), + type: z.string().max(200).optional(), + message: z.string().max(2000).optional(), + duration: z.number().optional(), + path: z.string().max(2000).optional(), + label: z.string().max(500).optional(), + value: z.union([z.string().max(500), z.number()]).optional(), + }).optional().default({}), +}); + export async function POST(request: Request) { try { + const ip = getClientIP(request); + + // Rate limit by IP + const rateLimitResult = await checkDistributedRateLimit( + `track:ip:${ip}`, + DISTRIBUTED_RATE_LIMITS.TRACKING + ); + + if (!rateLimitResult.allowed) { + return Response.json( + { error: 'Too many requests' }, + { + status: 429, + headers: { 'Retry-After': String(rateLimitResult.retryAfter || 60) }, + } + ); + } + + // Check payload size via Content-Length header + const contentLength = parseInt(request.headers.get('content-length') || '0', 10); + if (contentLength > MAX_PAYLOAD_BYTES) { + return Response.json( + { error: 'Payload too large' }, + { status: 413 } + ); + } + + // Read body as text first to verify actual byte size + const rawBody = await request.text(); + if (Buffer.byteLength(rawBody, 'utf-8') > MAX_PAYLOAD_BYTES) { + return Response.json( + { error: 'Payload too large' }, + { status: 413 } + ); + } + + // Parse JSON + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return Response.json({ ok: true }); + } + + // Schema validation + const validation = trackingSchema.safeParse(parsed); + if (!validation.success) { + return Response.json( + { error: 'Invalid tracking payload' }, + { status: 400 } + ); + } + + const { event, data } = validation.data; + // Try to get user ID but don't block if auth fails let userId: string | undefined; const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS); if (!isAuthError(auth)) { userId = auth.userId; } - - // Get client IP and user agent - const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || - request.headers.get('x-real-ip') || - 'unknown'; + const userAgent = request.headers.get('user-agent') || 'unknown'; - - // Parse tracking data - const body = await request.json().catch(() => null); - if (!body || !body.event) { - // Still return success - don't break client - return NextResponse.json({ ok: true }); - } - const { event, data = {} } = body; - - // Add request context to data const enrichedData = { ...data, ip, userAgent, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }; // Route different event types @@ -52,70 +123,58 @@ export async function POST(request: Request) { trackActivity(userId, 'page_view', { metadata: enrichedData, ip, - userAgent + userAgent, }); break; - + case 'feature_used': - trackFeature(userId, data.feature, enrichedData); + trackFeature(userId, data.feature || 'unknown', enrichedData); break; - + case 'user_action': trackActivity(userId, data.action || event, { resource: data.resource, resourceId: data.resourceId, metadata: enrichedData, ip, - userAgent + userAgent, }); break; - + case 'search': trackActivity(userId, 'search', { metadata: enrichedData, ip, - userAgent + userAgent, }); break; - + case 'click': trackActivity(userId, 'ui_click', { metadata: enrichedData, ip, - userAgent + userAgent, }); break; - + case 'client_error': - trackError(userId, data.type || 'client', data.message, enrichedData); + trackError(userId, data.type || 'client', data.message || 'Unknown error', enrichedData); break; - + case 'timing': - // Only track slow operations - if (data.duration > 3000) { + if (data.duration && data.duration > 3000) { trackActivity(userId, 'slow_operation', { metadata: enrichedData, ip, - userAgent + userAgent, }); } break; - - default: - // Generic event tracking - trackActivity(userId, event, { - metadata: enrichedData, - ip, - userAgent - }); } - - // Always return success quickly - return NextResponse.json({ ok: true }); - + + return Response.json({ ok: true }); } catch { - // Never fail - tracking should not impact user experience - return NextResponse.json({ ok: true }); + return Response.json({ ok: true }); } } @@ -123,17 +182,25 @@ export async function POST(request: Request) { export async function PUT(request: Request) { try { const text = await request.text(); + + // Check payload size + if (text.length > MAX_PAYLOAD_BYTES) { + return Response.json( + { error: 'Payload too large' }, + { status: 413 } + ); + } + const body = JSON.parse(text); - - // Reuse POST logic + const newRequest = new Request(request.url, { method: 'POST', headers: request.headers, - body: JSON.stringify(body) + body: JSON.stringify(body), }); - + return POST(newRequest); } catch { - return NextResponse.json({ ok: true }); + return Response.json({ ok: true }); } -} \ No newline at end of file +} diff --git a/packages/lib/src/security/__tests__/distributed-rate-limit.test.ts b/packages/lib/src/security/__tests__/distributed-rate-limit.test.ts index 51204826a..230e54a0b 100644 --- a/packages/lib/src/security/__tests__/distributed-rate-limit.test.ts +++ b/packages/lib/src/security/__tests__/distributed-rate-limit.test.ts @@ -399,12 +399,18 @@ describe('distributed-rate-limit', () => { expect(DISTRIBUTED_RATE_LIMITS.SERVICE_TOKEN.windowMs).toBe(60 * 1000); }); - it('CONTACT_FORM has 5 attempts per hour', () => { - expect(DISTRIBUTED_RATE_LIMITS.CONTACT_FORM.maxAttempts).toBe(5); - expect(DISTRIBUTED_RATE_LIMITS.CONTACT_FORM.windowMs).toBe(60 * 60 * 1000); + it('CONTACT_FORM has 10 attempts per minute', () => { + expect(DISTRIBUTED_RATE_LIMITS.CONTACT_FORM.maxAttempts).toBe(10); + expect(DISTRIBUTED_RATE_LIMITS.CONTACT_FORM.windowMs).toBe(60 * 1000); expect(DISTRIBUTED_RATE_LIMITS.CONTACT_FORM.progressiveDelay).toBe(false); }); + it('TRACKING has 100 attempts per minute', () => { + expect(DISTRIBUTED_RATE_LIMITS.TRACKING.maxAttempts).toBe(100); + expect(DISTRIBUTED_RATE_LIMITS.TRACKING.windowMs).toBe(60 * 1000); + expect(DISTRIBUTED_RATE_LIMITS.TRACKING.progressiveDelay).toBe(false); + }); + it('EMAIL_RESEND has 3 attempts per hour', () => { expect(DISTRIBUTED_RATE_LIMITS.EMAIL_RESEND.maxAttempts).toBe(3); expect(DISTRIBUTED_RATE_LIMITS.EMAIL_RESEND.windowMs).toBe(60 * 60 * 1000); diff --git a/packages/lib/src/security/distributed-rate-limit.ts b/packages/lib/src/security/distributed-rate-limit.ts index e0f979445..f5d98ae13 100644 --- a/packages/lib/src/security/distributed-rate-limit.ts +++ b/packages/lib/src/security/distributed-rate-limit.ts @@ -389,9 +389,15 @@ export const DISTRIBUTED_RATE_LIMITS = { progressiveDelay: false, }, CONTACT_FORM: { - maxAttempts: 5, - windowMs: 60 * 60 * 1000, // 1 hour - blockDurationMs: 60 * 60 * 1000, + maxAttempts: 10, + windowMs: 60 * 1000, // 1 minute + blockDurationMs: 60 * 1000, + progressiveDelay: false, + }, + TRACKING: { + maxAttempts: 100, + windowMs: 60 * 1000, // 1 minute + blockDurationMs: 60 * 1000, progressiveDelay: false, }, EMAIL_RESEND: {