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