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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user