Security hardening for production readiness

- Add security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, etc.)
- Strengthen password requirements (10+ chars, mixed case, numbers)
- Increase shared list slug entropy from 4 to 16 bytes
- Add rate limiting to login, registration, upload, and restore endpoints
- Add file magic number validation for image uploads (JPEG, PNG, WebP, HEIC)
- Add CSV row limit (50k) to restore endpoint
- Update client-side registration form to match new password policy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JP Scott
2026-03-01 12:55:16 -07:00
parent 969bc9347a
commit 8a582bfa7f
8 changed files with 149 additions and 10 deletions

View File

@@ -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() {
<Input
id="password"
type="password"
placeholder="At least 8 characters"
placeholder="Min 10 chars, upper+lower+number"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
minLength={10}
maxLength={128}
disabled={loading}
/>
</div>
@@ -130,7 +136,7 @@ export default function RegisterPage() {
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
minLength={10}
disabled={loading}
/>
</div>

View File

@@ -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)

View File

@@ -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(

View File

@@ -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: {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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({