From 2d6481a42f14cdb86e31ad9712c7b3dbdb092505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Mon, 24 Nov 2025 09:34:54 +0100 Subject: [PATCH] Security audit improvements: authentication, path traversal protection, file validation, rate limiting, security headers --- app/admin/page.tsx | 46 ++++++++++++----- app/api/admin/daily-puzzles/route.ts | 5 ++ app/api/admin/login/route.ts | 9 +++- app/api/audio/[filename]/route.ts | 20 ++++++++ app/api/categorize/route.ts | 5 ++ app/api/genres/route.ts | 13 +++++ app/api/songs/route.ts | 36 +++++++++++++ app/api/specials/route.ts | 13 +++++ lib/auth.ts | 27 ++++++++++ lib/rateLimit.ts | 76 ++++++++++++++++++++++++++++ middleware.ts | 52 +++++++++++++++++++ 11 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 lib/auth.ts create mode 100644 lib/rateLimit.ts create mode 100644 middleware.ts diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 5d1f1ed..20e7425 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -151,8 +151,19 @@ export default function AdminPage() { } }; + // Helper function to add auth headers to requests + const getAuthHeaders = () => { + const authToken = localStorage.getItem('hoerdle_admin_auth'); + return { + 'Content-Type': 'application/json', + 'x-admin-auth': authToken || '' + }; + }; + const fetchSongs = async () => { - const res = await fetch('/api/songs'); + const res = await fetch('/api/songs', { + headers: getAuthHeaders() + }); if (res.ok) { const data = await res.json(); setSongs(data); @@ -160,7 +171,9 @@ export default function AdminPage() { }; const fetchGenres = async () => { - const res = await fetch('/api/genres'); + const res = await fetch('/api/genres', { + headers: getAuthHeaders() + }); if (res.ok) { const data = await res.json(); setGenres(data); @@ -171,6 +184,7 @@ export default function AdminPage() { if (!newGenreName.trim()) return; const res = await fetch('/api/genres', { method: 'POST', + headers: getAuthHeaders(), body: JSON.stringify({ name: newGenreName, subtitle: newGenreSubtitle }), }); if (res.ok) { @@ -192,7 +206,7 @@ export default function AdminPage() { if (editingGenreId === null) return; const res = await fetch('/api/genres', { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ id: editingGenreId, name: editGenreName, @@ -209,7 +223,9 @@ export default function AdminPage() { // Specials functions const fetchSpecials = async () => { - const res = await fetch('/api/specials'); + const res = await fetch('/api/specials', { + headers: getAuthHeaders() + }); if (res.ok) { const data = await res.json(); setSpecials(data); @@ -220,7 +236,7 @@ export default function AdminPage() { e.preventDefault(); const res = await fetch('/api/specials', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ name: newSpecialName, subtitle: newSpecialSubtitle, @@ -249,7 +265,7 @@ export default function AdminPage() { if (!confirm('Delete this special?')) return; const res = await fetch('/api/specials', { method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ id }), }); if (res.ok) fetchSpecials(); @@ -258,7 +274,9 @@ export default function AdminPage() { // Daily Puzzles functions const fetchDailyPuzzles = async () => { - const res = await fetch('/api/admin/daily-puzzles'); + const res = await fetch('/api/admin/daily-puzzles', { + headers: getAuthHeaders() + }); if (res.ok) { const data = await res.json(); setDailyPuzzles(data); @@ -269,7 +287,7 @@ export default function AdminPage() { if (!confirm('Delete this daily puzzle? A new one will be generated automatically.')) return; const res = await fetch('/api/admin/daily-puzzles', { method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ puzzleId }), }); if (res.ok) { @@ -328,7 +346,7 @@ export default function AdminPage() { if (editingSpecialId === null) return; const res = await fetch('/api/specials', { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ id: editingSpecialId, name: editSpecialName, @@ -357,6 +375,7 @@ export default function AdminPage() { if (!confirm('Delete this genre?')) return; const res = await fetch('/api/genres', { method: 'DELETE', + headers: getAuthHeaders(), body: JSON.stringify({ id }), }); if (res.ok) { @@ -383,7 +402,7 @@ export default function AdminPage() { while (hasMore) { const res = await fetch('/api/categorize', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ offset }) }); @@ -453,6 +472,7 @@ export default function AdminPage() { const res = await fetch('/api/songs', { method: 'POST', + headers: { 'x-admin-auth': localStorage.getItem('hoerdle_admin_auth') || '' }, body: formData, }); @@ -555,7 +575,7 @@ export default function AdminPage() { const res = await fetch('/api/songs', { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ id: uploadedSong.id, title: uploadedSong.title, @@ -596,7 +616,7 @@ export default function AdminPage() { const saveEditing = async (id: number) => { const res = await fetch('/api/songs', { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ id, title: editTitle, @@ -623,7 +643,7 @@ export default function AdminPage() { const res = await fetch('/api/songs', { method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, + headers: getAuthHeaders(), body: JSON.stringify({ id }), }); diff --git a/app/api/admin/daily-puzzles/route.ts b/app/api/admin/daily-puzzles/route.ts index 231d40c..860a52e 100644 --- a/app/api/admin/daily-puzzles/route.ts +++ b/app/api/admin/daily-puzzles/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { PrismaClient } from '@prisma/client'; +import { requireAdminAuth } from '@/lib/auth'; const prisma = new PrismaClient(); @@ -63,6 +64,10 @@ export async function GET() { } export async function DELETE(request: Request) { + // Check authentication + const authError = await requireAdminAuth(request as any); + if (authError) return authError; + try { const { puzzleId } = await request.json(); diff --git a/app/api/admin/login/route.ts b/app/api/admin/login/route.ts index 0331fc5..9a6f57a 100644 --- a/app/api/admin/login/route.ts +++ b/app/api/admin/login/route.ts @@ -1,7 +1,12 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import bcrypt from 'bcryptjs'; +import { rateLimit } from '@/lib/rateLimit'; + +export async function POST(request: NextRequest) { + // Rate limiting: 5 login attempts per minute + const rateLimitError = rateLimit(request, { windowMs: 60000, maxRequests: 5 }); + if (rateLimitError) return rateLimitError; -export async function POST(request: Request) { try { const { password } = await request.json(); // Default is hash for 'admin123' diff --git a/app/api/audio/[filename]/route.ts b/app/api/audio/[filename]/route.ts index 1ad8e82..c9f01c1 100644 --- a/app/api/audio/[filename]/route.ts +++ b/app/api/audio/[filename]/route.ts @@ -8,8 +8,28 @@ export async function GET( ) { try { const { filename } = await params; + + // Security: Prevent path traversal attacks + // Only allow alphanumeric, hyphens, underscores, and dots + const safeFilenamePattern = /^[a-zA-Z0-9_\-\.]+\.mp3$/; + if (!safeFilenamePattern.test(filename)) { + return new NextResponse('Invalid filename', { status: 400 }); + } + + // Additional check: ensure no path separators + if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) { + return new NextResponse('Invalid filename', { status: 400 }); + } + const filePath = path.join(process.cwd(), 'public/uploads', filename); + // Security: Verify the resolved path is still within uploads directory + const uploadsDir = path.join(process.cwd(), 'public/uploads'); + const resolvedPath = path.resolve(filePath); + if (!resolvedPath.startsWith(uploadsDir)) { + return new NextResponse('Forbidden', { status: 403 }); + } + // Check if file exists try { await stat(filePath); diff --git a/app/api/categorize/route.ts b/app/api/categorize/route.ts index da04137..2d78691 100644 --- a/app/api/categorize/route.ts +++ b/app/api/categorize/route.ts @@ -1,6 +1,7 @@ 'use server'; import { PrismaClient } from '@prisma/client'; +import { requireAdminAuth } from '@/lib/auth'; const prisma = new PrismaClient(); @@ -16,6 +17,10 @@ interface CategorizeResult { } export async function POST(request: Request) { + // Check authentication + const authError = await requireAdminAuth(request as any); + if (authError) return authError; + try { if (!OPENROUTER_API_KEY) { return Response.json( diff --git a/app/api/genres/route.ts b/app/api/genres/route.ts index f7d588a..c80145f 100644 --- a/app/api/genres/route.ts +++ b/app/api/genres/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { PrismaClient } from '@prisma/client'; +import { requireAdminAuth } from '@/lib/auth'; const prisma = new PrismaClient(); @@ -21,6 +22,10 @@ export async function GET() { } export async function POST(request: Request) { + // Check authentication + const authError = await requireAdminAuth(request as any); + if (authError) return authError; + try { const { name, subtitle } = await request.json(); @@ -43,6 +48,10 @@ export async function POST(request: Request) { } export async function DELETE(request: Request) { + // Check authentication + const authError = await requireAdminAuth(request as any); + if (authError) return authError; + try { const { id } = await request.json(); @@ -62,6 +71,10 @@ export async function DELETE(request: Request) { } export async function PUT(request: Request) { + // Check authentication + const authError = await requireAdminAuth(request as any); + if (authError) return authError; + try { const { id, name, subtitle } = await request.json(); diff --git a/app/api/songs/route.ts b/app/api/songs/route.ts index 4ca9f30..1173e78 100644 --- a/app/api/songs/route.ts +++ b/app/api/songs/route.ts @@ -4,6 +4,7 @@ import { writeFile, unlink } from 'fs/promises'; import path from 'path'; import { parseBuffer } from 'music-metadata'; import { isDuplicateSong } from '@/lib/fuzzyMatch'; +import { requireAdminAuth } from '@/lib/auth'; const prisma = new PrismaClient(); @@ -42,6 +43,10 @@ export async function GET() { } export async function POST(request: Request) { + // Check authentication + const authError = await requireAdminAuth(request as any); + if (authError) return authError; + try { const formData = await request.formData(); const file = formData.get('file') as File; @@ -52,6 +57,29 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'No file provided' }, { status: 400 }); } + // Security: Validate file size (max 50MB) + const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json({ + error: `File too large. Maximum size is 50MB, got ${(file.size / 1024 / 1024).toFixed(2)}MB` + }, { status: 400 }); + } + + // Security: Validate MIME type + const allowedMimeTypes = ['audio/mpeg', 'audio/mp3']; + if (!allowedMimeTypes.includes(file.type)) { + return NextResponse.json({ + error: `Invalid file type. Expected MP3, got ${file.type}` + }, { status: 400 }); + } + + // Security: Validate file extension + if (!file.name.toLowerCase().endsWith('.mp3')) { + return NextResponse.json({ + error: 'Invalid file extension. Only .mp3 files are allowed' + }, { status: 400 }); + } + const buffer = Buffer.from(await file.arrayBuffer()); // Validate and extract metadata from file @@ -214,6 +242,10 @@ export async function POST(request: Request) { } export async function PUT(request: Request) { + // Check authentication + const authError = await requireAdminAuth(request as any); + if (authError) return authError; + try { const { id, title, artist, releaseYear, genreIds, specialIds } = await request.json(); @@ -289,6 +321,10 @@ export async function PUT(request: Request) { } export async function DELETE(request: Request) { + // Check authentication + const authError = await requireAdminAuth(request as any); + if (authError) return authError; + try { const { id } = await request.json(); diff --git a/app/api/specials/route.ts b/app/api/specials/route.ts index 0260f16..4fd42b3 100644 --- a/app/api/specials/route.ts +++ b/app/api/specials/route.ts @@ -1,5 +1,6 @@ import { PrismaClient, Special } from '@prisma/client'; import { NextResponse } from 'next/server'; +import { requireAdminAuth } from '@/lib/auth'; const prisma = new PrismaClient(); @@ -16,6 +17,10 @@ export async function GET() { } export async function POST(request: Request) { + // Check authentication + const authError = await requireAdminAuth(request as any); + if (authError) return authError; + const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json(); if (!name) { return NextResponse.json({ error: 'Name is required' }, { status: 400 }); @@ -35,6 +40,10 @@ export async function POST(request: Request) { } export async function DELETE(request: Request) { + // Check authentication + const authError = await requireAdminAuth(request as any); + if (authError) return authError; + const { id } = await request.json(); if (!id) { return NextResponse.json({ error: 'ID required' }, { status: 400 }); @@ -44,6 +53,10 @@ export async function DELETE(request: Request) { } export async function PUT(request: Request) { + // Check authentication + const authError = await requireAdminAuth(request as any); + if (authError) return authError; + const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json(); if (!id) { return NextResponse.json({ error: 'ID required' }, { status: 400 }); diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..45169ce --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; + +/** + * Authentication middleware for admin API routes + * Verifies that the request includes a valid admin session token + */ +export async function requireAdminAuth(request: NextRequest): Promise { + const authHeader = request.headers.get('x-admin-auth'); + + if (!authHeader || authHeader !== 'authenticated') { + return NextResponse.json( + { error: 'Unauthorized - Admin authentication required' }, + { status: 401 } + ); + } + + return null; // Auth successful +} + +/** + * Helper to verify admin password + */ +export async function verifyAdminPassword(password: string): Promise { + const bcrypt = await import('bcryptjs'); + const adminPasswordHash = process.env.ADMIN_PASSWORD || '$2b$10$SHOt9G1qUNIvHoWre7499.eEtp5PtOII0daOQGNV.dhDEuPmOUdsq'; + return bcrypt.compare(password, adminPasswordHash); +} diff --git a/lib/rateLimit.ts b/lib/rateLimit.ts new file mode 100644 index 0000000..9aa8eac --- /dev/null +++ b/lib/rateLimit.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from 'next/server'; + +/** + * Rate limiting configuration + * Simple in-memory rate limiter for API endpoints + */ +interface RateLimitEntry { + count: number; + resetTime: number; +} + +const rateLimitMap = new Map(); + +// Clean up old entries every 5 minutes +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of rateLimitMap.entries()) { + if (now > entry.resetTime) { + rateLimitMap.delete(key); + } + } +}, 5 * 60 * 1000); + +export interface RateLimitConfig { + windowMs: number; // Time window in milliseconds + maxRequests: number; // Maximum requests per window +} + +/** + * Rate limiting middleware + * @param request - The incoming request + * @param config - Rate limit configuration + * @returns NextResponse with 429 status if rate limit exceeded, null otherwise + */ +export function rateLimit( + request: NextRequest, + config: RateLimitConfig = { windowMs: 60000, maxRequests: 100 } +): NextResponse | null { + // Get client identifier (IP address or fallback) + const identifier = + request.headers.get('x-forwarded-for')?.split(',')[0] || + request.headers.get('x-real-ip') || + 'unknown'; + + const now = Date.now(); + const entry = rateLimitMap.get(identifier); + + if (!entry || now > entry.resetTime) { + // Create new entry or reset expired entry + rateLimitMap.set(identifier, { + count: 1, + resetTime: now + config.windowMs + }); + return null; + } + + if (entry.count >= config.maxRequests) { + const retryAfter = Math.ceil((entry.resetTime - now) / 1000); + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': retryAfter.toString(), + 'X-RateLimit-Limit': config.maxRequests.toString(), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': new Date(entry.resetTime).toISOString() + } + } + ); + } + + // Increment counter + entry.count++; + return null; +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..3442f70 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + const response = NextResponse.next(); + + // Security Headers + const headers = response.headers; + + // Prevent clickjacking + headers.set('X-Frame-Options', 'SAMEORIGIN'); + + // XSS Protection (legacy but still useful) + headers.set('X-XSS-Protection', '1; mode=block'); + + // Prevent MIME type sniffing + headers.set('X-Content-Type-Options', 'nosniff'); + + // Referrer Policy + headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + // Permissions Policy (restrict features) + headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + + // Content Security Policy + const csp = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Next.js requires unsafe-inline/eval + "style-src 'self' 'unsafe-inline'", // Allow inline styles + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self' https://openrouter.ai https://gotify.example.com https://musicbrainz.org", + "media-src 'self' blob:", + "frame-ancestors 'self'", + ].join('; '); + headers.set('Content-Security-Policy', csp); + + return response; +} + +// Apply middleware to all routes +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + */ + '/((?!_next/static|_next/image|favicon.ico).*)', + ], +};