diff --git a/next.config.mjs b/next.config.mjs index 7e94dff..842fe14 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -16,6 +16,53 @@ const nextConfig = { }, ], }, + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "X-DNS-Prefetch-Control", + value: "on", + }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { + key: "Permissions-Policy", + value: "camera=(self), microphone=(), geolocation=(), interest-cohort=()", + }, + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: http://localhost:9000 https://*.amazonaws.com", + "font-src 'self'", + "connect-src 'self' http://localhost:9000", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "), + }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index b0ae610..b019fc2 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -26,8 +26,13 @@ export default function RegisterPage() { return } - if (password.length < 8) { - setError("Password must be at least 8 characters") + if (password.length < 10) { + setError("Password must be at least 10 characters") + return + } + + if (!/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/[0-9]/.test(password)) { + setError("Password must contain lowercase, uppercase, and a number") return } @@ -112,11 +117,12 @@ export default function RegisterPage() { setPassword(e.target.value)} required - minLength={8} + minLength={10} + maxLength={128} disabled={loading} /> @@ -130,7 +136,7 @@ export default function RegisterPage() { value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required - minLength={8} + minLength={10} disabled={loading} /> diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index c6731d8..c8c30b0 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server" import { z } from "zod" import bcrypt from "bcryptjs" import { prisma } from "@/lib/prisma" +import { rateLimit } from "@/lib/rate-limit" const registerSchema = z.object({ name: z @@ -14,11 +15,26 @@ const registerSchema = z.object({ .email("Invalid email address"), password: z .string() - .min(8, "Password must be at least 8 characters"), + .min(10, "Password must be at least 10 characters") + .max(128, "Password must be 128 characters or less") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[0-9]/, "Password must contain at least one number"), }) export async function POST(request: Request) { try { + // Rate limit: 5 registration attempts per IP per minute + const forwarded = request.headers.get("x-forwarded-for") + const ip = forwarded?.split(",")[0]?.trim() ?? "unknown" + const rl = rateLimit(`register:${ip}`, 5, 60 * 1000) + if (!rl.success) { + return NextResponse.json( + { error: "Too many registration attempts. Please try again later." }, + { status: 429 } + ) + } + const body = await request.json() const result = registerSchema.safeParse(body) diff --git a/src/app/api/settings/restore/route.ts b/src/app/api/settings/restore/route.ts index 89ce5bf..fc7e04d 100644 --- a/src/app/api/settings/restore/route.ts +++ b/src/app/api/settings/restore/route.ts @@ -6,8 +6,10 @@ import { validateBackupData, executeRestore, } from "@/lib/backup" +import { rateLimit } from "@/lib/rate-limit" const VALID_MODES = ["merge-skip", "merge-update", "replace"] as const +const MAX_CSV_ROWS = 50000 export async function POST(request: Request) { const session = await auth() @@ -15,6 +17,15 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } + // Rate limit: 3 restores per user per hour + const rl = rateLimit(`restore:${session.user.id}`, 3, 60 * 60 * 1000) + if (!rl.success) { + return NextResponse.json( + { error: "Too many restore attempts. Please try again later." }, + { status: 429 } + ) + } + try { const formData = await request.formData() const file = formData.get("file") as File | null @@ -59,6 +70,13 @@ export async function POST(request: Request) { ) } + if (rows.length > MAX_CSV_ROWS) { + return NextResponse.json( + { error: `CSV exceeds maximum of ${MAX_CSV_ROWS.toLocaleString()} rows` }, + { status: 400 } + ) + } + // Check for _type column if (!("_type" in rows[0])) { return NextResponse.json( diff --git a/src/app/api/shared-lists/route.ts b/src/app/api/shared-lists/route.ts index e02e452..c4ef15c 100644 --- a/src/app/api/shared-lists/route.ts +++ b/src/app/api/shared-lists/route.ts @@ -34,7 +34,7 @@ export async function POST(request: Request) { ) } - const slug = randomBytes(4).toString("hex") + const slug = randomBytes(16).toString("hex") const list = await prisma.sharedList.create({ data: { diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 5c479b2..883efab 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -2,6 +2,34 @@ import { NextResponse } from "next/server" import { auth } from "@/lib/auth" import { uploadImage } from "@/lib/s3" import { randomUUID } from "crypto" +import { rateLimit } from "@/lib/rate-limit" + +// Magic number signatures for allowed image types +const MAGIC_NUMBERS: { type: string; bytes: number[]; offset?: number }[] = [ + { type: "image/jpeg", bytes: [0xff, 0xd8, 0xff] }, + { type: "image/png", bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }, + { type: "image/webp", bytes: [0x52, 0x49, 0x46, 0x46] }, // "RIFF" + check "WEBP" at offset 8 + { type: "image/heic", bytes: [0x66, 0x74, 0x79, 0x70], offset: 4 }, // "ftyp" at offset 4 +] + +function detectImageType(buffer: Buffer): string | null { + for (const sig of MAGIC_NUMBERS) { + const offset = sig.offset ?? 0 + if (buffer.length < offset + sig.bytes.length) continue + const match = sig.bytes.every((b, i) => buffer[offset + i] === b) + if (match) { + // Extra check for WebP: bytes 8-11 must be "WEBP" + if (sig.type === "image/webp") { + if (buffer.length >= 12 && buffer.toString("ascii", 8, 12) === "WEBP") { + return sig.type + } + continue + } + return sig.type + } + } + return null +} export async function POST(request: Request) { const session = await auth() @@ -9,6 +37,15 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } + // Rate limit: 20 uploads per user per minute + const rl = rateLimit(`upload:${session.user.id}`, 20, 60 * 1000) + if (!rl.success) { + return NextResponse.json( + { error: "Too many uploads. Please try again later." }, + { status: 429 } + ) + } + try { const formData = await request.formData() const file = formData.get("file") as File | null @@ -34,10 +71,20 @@ export async function POST(request: Request) { } const buffer = Buffer.from(await file.arrayBuffer()) - const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1] + + // Validate actual file content matches claimed MIME type + const detectedType = detectImageType(buffer) + if (!detectedType || !allowedTypes.includes(detectedType)) { + return NextResponse.json( + { error: "File content does not match an allowed image type" }, + { status: 400 } + ) + } + + const ext = detectedType.split("/")[1] === "jpeg" ? "jpg" : detectedType.split("/")[1] const key = `${session.user.id}/${randomUUID()}.${ext}` - const url = await uploadImage(key, buffer, file.type) + const url = await uploadImage(key, buffer, detectedType) return NextResponse.json({ url, key }) } catch (error) { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index e99744c..42a8d31 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -4,6 +4,7 @@ import GitHub from "next-auth/providers/github" import Credentials from "next-auth/providers/credentials" import { PrismaAdapter } from "@auth/prisma-adapter" import { prisma } from "@/lib/prisma" +import { rateLimit } from "@/lib/rate-limit" const providers = [ Google({ @@ -25,6 +26,10 @@ const providers = [ const password = credentials?.password as string if (!email || !password) return null + // Rate limit: 10 login attempts per email per 15 minutes + const rl = rateLimit(`login:${email.toLowerCase()}`, 10, 15 * 60 * 1000) + if (!rl.success) return null + const user = await prisma.user.findUnique({ where: { email } }) if (!user || !user.password) return null diff --git a/src/lib/backup.ts b/src/lib/backup.ts index 96b98b1..7430a4e 100644 --- a/src/lib/backup.ts +++ b/src/lib/backup.ts @@ -652,7 +652,7 @@ export async function executeRestore( .map((oldId) => drinkIdMap.get(oldId)) .filter(Boolean) as string[] - const slug = crypto.randomBytes(6).toString("hex") + const slug = crypto.randomBytes(16).toString("hex") if (mode === "replace") { await tx.sharedList.create({