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

@@ -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; export default nextConfig;

View File

@@ -26,8 +26,13 @@ export default function RegisterPage() {
return return
} }
if (password.length < 8) { if (password.length < 10) {
setError("Password must be at least 8 characters") 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 return
} }
@@ -112,11 +117,12 @@ export default function RegisterPage() {
<Input <Input
id="password" id="password"
type="password" type="password"
placeholder="At least 8 characters" placeholder="Min 10 chars, upper+lower+number"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
minLength={8} minLength={10}
maxLength={128}
disabled={loading} disabled={loading}
/> />
</div> </div>
@@ -130,7 +136,7 @@ export default function RegisterPage() {
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
required required
minLength={8} minLength={10}
disabled={loading} disabled={loading}
/> />
</div> </div>

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
import { z } from "zod" import { z } from "zod"
import bcrypt from "bcryptjs" import bcrypt from "bcryptjs"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { rateLimit } from "@/lib/rate-limit"
const registerSchema = z.object({ const registerSchema = z.object({
name: z name: z
@@ -14,11 +15,26 @@ const registerSchema = z.object({
.email("Invalid email address"), .email("Invalid email address"),
password: z password: z
.string() .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) { export async function POST(request: Request) {
try { 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 body = await request.json()
const result = registerSchema.safeParse(body) const result = registerSchema.safeParse(body)

View File

@@ -6,8 +6,10 @@ import {
validateBackupData, validateBackupData,
executeRestore, executeRestore,
} from "@/lib/backup" } from "@/lib/backup"
import { rateLimit } from "@/lib/rate-limit"
const VALID_MODES = ["merge-skip", "merge-update", "replace"] as const const VALID_MODES = ["merge-skip", "merge-update", "replace"] as const
const MAX_CSV_ROWS = 50000
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await auth() const session = await auth()
@@ -15,6 +17,15 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) 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 { try {
const formData = await request.formData() const formData = await request.formData()
const file = formData.get("file") as File | null 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 // Check for _type column
if (!("_type" in rows[0])) { if (!("_type" in rows[0])) {
return NextResponse.json( 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({ const list = await prisma.sharedList.create({
data: { data: {

View File

@@ -2,6 +2,34 @@ import { NextResponse } from "next/server"
import { auth } from "@/lib/auth" import { auth } from "@/lib/auth"
import { uploadImage } from "@/lib/s3" import { uploadImage } from "@/lib/s3"
import { randomUUID } from "crypto" 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) { export async function POST(request: Request) {
const session = await auth() const session = await auth()
@@ -9,6 +37,15 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) 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 { try {
const formData = await request.formData() const formData = await request.formData()
const file = formData.get("file") as File | null 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 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 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 }) return NextResponse.json({ url, key })
} catch (error) { } catch (error) {

View File

@@ -4,6 +4,7 @@ import GitHub from "next-auth/providers/github"
import Credentials from "next-auth/providers/credentials" import Credentials from "next-auth/providers/credentials"
import { PrismaAdapter } from "@auth/prisma-adapter" import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { rateLimit } from "@/lib/rate-limit"
const providers = [ const providers = [
Google({ Google({
@@ -25,6 +26,10 @@ const providers = [
const password = credentials?.password as string const password = credentials?.password as string
if (!email || !password) return null 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 } }) const user = await prisma.user.findUnique({ where: { email } })
if (!user || !user.password) return null if (!user || !user.password) return null

View File

@@ -652,7 +652,7 @@ export async function executeRestore(
.map((oldId) => drinkIdMap.get(oldId)) .map((oldId) => drinkIdMap.get(oldId))
.filter(Boolean) as string[] .filter(Boolean) as string[]
const slug = crypto.randomBytes(6).toString("hex") const slug = crypto.randomBytes(16).toString("hex")
if (mode === "replace") { if (mode === "replace") {
await tx.sharedList.create({ await tx.sharedList.create({