diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index 1c3b276..7ada708 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import Stripe from "stripe"; -import { CartItem } from "@/types/merch"; +import { checkoutSchema } from "@/lib/validations"; +import { checkoutRateLimiter, getClientIdentifier } from "@/lib/rate-limit"; +import { ZodError } from "zod"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2025-12-15.clover", @@ -8,12 +10,30 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { export async function POST(request: NextRequest) { try { - const { items } = (await request.json()) as { items: CartItem[] }; - - if (!items || items.length === 0) { - return NextResponse.json({ error: "No items in cart" }, { status: 400 }); + const identifier = getClientIdentifier(request); + const rateLimit = checkoutRateLimiter.check(identifier); + + if (!rateLimit.allowed) { + return NextResponse.json( + { + error: "Rate limit exceeded. Please try again later.", + retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) + }, + { + status: 429, + headers: { + "X-RateLimit-Limit": "10", + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": new Date(rateLimit.resetTime).toISOString(), + } + } + ); } + const body = await request.json(); + const validatedData = checkoutSchema.parse(body); + const { items } = validatedData; + const origin = request.headers.get("origin") || "http://localhost:3000"; const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = items.map( @@ -68,6 +88,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ url: session.url }); } catch (error) { + if (error instanceof ZodError) { + return NextResponse.json( + { error: "Invalid cart data", details: error.errors }, + { status: 400 } + ); + } console.error("Stripe checkout error:", error); return NextResponse.json( { error: "Failed to create checkout session" }, diff --git a/app/api/love-notes/send/route.ts b/app/api/love-notes/send/route.ts index 4413d1c..7aa964a 100644 --- a/app/api/love-notes/send/route.ts +++ b/app/api/love-notes/send/route.ts @@ -1,33 +1,59 @@ import { NextRequest, NextResponse } from "next/server"; import { Resend } from "resend"; +import { loveNoteEmailSchema } from "@/lib/validations"; +import { emailRateLimiter, getClientIdentifier } from "@/lib/rate-limit"; +import { ZodError } from "zod"; -// Set RESEND_API_KEY in .env.local (local) or in Vercel/hosting env vars (production). -// Get your key at https://resend.com/api-keys const resend = new Resend(process.env.RESEND_API_KEY); +function escapeHtml(unsafe: string): string { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + export async function POST(request: NextRequest) { try { - const { recipientEmail, senderName } = (await request.json()) as { - recipientEmail: string; - senderName: string; - }; - - if (!recipientEmail || !senderName) { + const identifier = getClientIdentifier(request); + const rateLimit = emailRateLimiter.check(identifier); + + if (!rateLimit.allowed) { return NextResponse.json( - { error: "Missing recipientEmail or senderName" }, - { status: 400 } + { + error: "Rate limit exceeded. Please try again later.", + retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) + }, + { + status: 429, + headers: { + "X-RateLimit-Limit": "5", + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": new Date(rateLimit.resetTime).toISOString(), + } + } ); } + const body = await request.json(); + + const validatedData = loveNoteEmailSchema.parse(body); + const { recipientEmail, senderName } = validatedData; + + const safeSenderName = escapeHtml(senderName); + const safeRecipientEmail = recipientEmail; + const { error } = await resend.emails.send({ from: "V1 @ Michigan ", - to: recipientEmail, + to: safeRecipientEmail, subject: "You've received a Valentine! 💌", html: `

You've got a Valentine's note!

- Hey there :) You have received a letter from ${senderName}. + Hey there :) You have received a letter from ${safeSenderName}. Login at v1michigan.com/valentines to see it!

@@ -47,6 +73,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true }); } catch (error) { + if (error instanceof ZodError) { + return NextResponse.json( + { error: "Invalid input", details: error.errors }, + { status: 400 } + ); + } console.error("Email send error:", error); return NextResponse.json( { error: "Failed to send email" }, diff --git a/components/people-content.tsx b/components/people-content.tsx index 38612a6..3fa3ff7 100644 --- a/components/people-content.tsx +++ b/components/people-content.tsx @@ -34,7 +34,10 @@ async function getPeople(searchQuery?: string) { .select('id, name, short-bio, full-bio, tags, linkedin, twitter, instagram, website, role, image-path'); if (searchQuery?.trim()) { - query = query.ilike('name', `%${searchQuery.trim()}%`); + const sanitized = searchQuery.trim().slice(0, 100); + if (sanitized.length > 0) { + query = query.ilike('name', `%${sanitized}%`); + } } const { data, error } = await query; diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts new file mode 100644 index 0000000..a609dda --- /dev/null +++ b/lib/rate-limit.ts @@ -0,0 +1,82 @@ +type RateLimitRecord = { + count: number; + resetTime: number; +}; + +export class RateLimiter { + private limitMap: Map; + private windowMs: number; + private maxRequests: number; + + constructor(windowMs: number, maxRequests: number) { + this.limitMap = new Map(); + this.windowMs = windowMs; + this.maxRequests = maxRequests; + } + + check(identifier: string): { allowed: boolean; remaining: number; resetTime: number } { + const now = Date.now(); + const record = this.limitMap.get(identifier); + + if (!record || now > record.resetTime) { + const newRecord = { count: 1, resetTime: now + this.windowMs }; + this.limitMap.set(identifier, newRecord); + this.cleanup(); + return { + allowed: true, + remaining: this.maxRequests - 1, + resetTime: newRecord.resetTime + }; + } + + if (record.count >= this.maxRequests) { + return { + allowed: false, + remaining: 0, + resetTime: record.resetTime + }; + } + + record.count++; + return { + allowed: true, + remaining: this.maxRequests - record.count, + resetTime: record.resetTime + }; + } + + private cleanup(): void { + const now = Date.now(); + const entriesToDelete: string[] = []; + + for (const [key, record] of this.limitMap.entries()) { + if (now > record.resetTime) { + entriesToDelete.push(key); + } + } + + if (entriesToDelete.length > 100) { + entriesToDelete.forEach(key => this.limitMap.delete(key)); + } + } + + reset(identifier: string): void { + this.limitMap.delete(identifier); + } + + clear(): void { + this.limitMap.clear(); + } +} + +export function getClientIdentifier(request: Request): string { + const headers = request.headers; + const forwarded = headers.get("x-forwarded-for"); + const realIp = headers.get("x-real-ip"); + const ip = forwarded?.split(",")[0].trim() || realIp || "unknown"; + return ip; +} + +export const emailRateLimiter = new RateLimiter(60 * 1000, 5); +export const checkoutRateLimiter = new RateLimiter(60 * 1000, 10); +export const apiRateLimiter = new RateLimiter(60 * 1000, 100); diff --git a/lib/validations.ts b/lib/validations.ts new file mode 100644 index 0000000..e08987c --- /dev/null +++ b/lib/validations.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; + +export const loveNoteEmailSchema = z.object({ + recipientEmail: z + .string() + .email("Invalid email address") + .max(254, "Email address too long") + .toLowerCase() + .trim(), + senderName: z + .string() + .min(1, "Sender name is required") + .max(100, "Sender name too long") + .trim() + .refine((val) => val.length > 0, "Sender name cannot be empty"), +}); + +export const loveNoteSchema = z.object({ + recipientName: z + .string() + .min(1, "Recipient name is required") + .max(100, "Recipient name too long") + .trim(), + recipientEmail: z + .string() + .email("Invalid email address") + .max(254, "Email address too long") + .toLowerCase() + .trim(), + messageText: z + .string() + .min(1, "Message is required") + .max(400, "Message too long (max 400 characters)") + .trim(), + backgroundColor: z + .string() + .regex(/^#[0-9A-Fa-f]{6}$/, "Invalid color format") + .default("#E11D48"), + templateId: z.string().uuid().optional().nullable(), +}); + +export const profileUpdateSchema = z.object({ + name: z + .string() + .min(1, "Name is required") + .max(100, "Name too long") + .trim(), + role: z + .string() + .max(100, "Role too long") + .trim(), + shortBio: z + .string() + .max(500, "Short bio too long (max 500 characters)") + .trim() + .optional(), + fullBio: z + .string() + .max(2000, "Full bio too long (max 2000 characters)") + .trim() + .optional(), + tags: z + .array( + z + .string() + .max(50, "Tag too long") + .regex(/^[a-zA-Z0-9\s-]+$/, "Tags can only contain letters, numbers, spaces, and hyphens") + ) + .max(20, "Too many tags (max 20)") + .optional(), + linkedin: z + .string() + .url("Invalid LinkedIn URL") + .max(255) + .optional() + .or(z.literal("")), + twitter: z + .string() + .url("Invalid Twitter URL") + .max(255) + .optional() + .or(z.literal("")), + instagram: z + .string() + .url("Invalid Instagram URL") + .max(255) + .optional() + .or(z.literal("")), + website: z + .string() + .url("Invalid website URL") + .max(255) + .optional() + .or(z.literal("")), +}); + +export const searchQuerySchema = z.object({ + q: z + .string() + .max(100, "Search query too long") + .trim() + .optional(), +}); + +export const checkoutSchema = z.object({ + items: z + .array( + z.object({ + product: z.object({ + id: z.string(), + name: z.string().max(100), + price: z.number().int().positive().max(1000000), + image: z.string().max(500), + }), + size: z.string().max(20), + color: z.string().max(50), + quantity: z.number().int().positive().max(99), + }) + ) + .min(1, "Cart cannot be empty") + .max(50, "Too many items in cart"), +}); + +export type LoveNoteEmail = z.infer; +export type LoveNote = z.infer; +export type ProfileUpdate = z.infer; +export type SearchQuery = z.infer; +export type CheckoutRequest = z.infer; diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..21a15eb --- /dev/null +++ b/middleware.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + const response = NextResponse.next(); + + const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); + + const cspHeader = ` + default-src 'self'; + script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://us.i.posthog.com; + style-src 'self' 'unsafe-inline'; + img-src 'self' data: https: blob:; + font-src 'self' data:; + connect-src 'self' https://*.supabase.co https://api.stripe.com https://us.i.posthog.com; + frame-src 'self' https://js.stripe.com; + object-src 'none'; + base-uri 'self'; + form-action 'self'; + frame-ancestors 'none'; + upgrade-insecure-requests; + `.replace(/\s{2,}/g, ' ').trim(); + + response.headers.set('Content-Security-Policy', cspHeader); + response.headers.set('X-DNS-Prefetch-Control', 'on'); + response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + response.headers.set('X-Frame-Options', 'DENY'); + response.headers.set('X-Content-Type-Options', 'nosniff'); + response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + + return response; +} + +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon.ico).*)', + ], +}; diff --git a/next.config.mjs b/next.config.mjs index 94c857a..06c8c6a 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -17,7 +17,6 @@ const nextConfig = { ignoreBuildErrors: true, }, images: { - // Enable Next.js image optimization for faster load times formats: ['image/avif', 'image/webp'], remotePatterns: [ { @@ -30,6 +29,48 @@ const nextConfig = { }, ], }, + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'X-Frame-Options', + value: 'DENY', + }, + { + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin', + }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()', + }, + ], + }, + { + source: '/api/:path*', + headers: [ + { + key: 'X-Frame-Options', + value: 'DENY', + }, + { + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + { + key: 'X-Robots-Tag', + value: 'noindex, nofollow', + }, + ], + }, + ]; + }, experimental: { webpackBuildWorker: true, parallelServerBuildTraces: true,