Security audit improvements: authentication, path traversal protection, file validation, rate limiting, security headers

This commit is contained in:
Hördle Bot
2025-11-24 09:34:54 +01:00
parent 0f7d66c619
commit 2d6481a42f
11 changed files with 287 additions and 15 deletions

View File

@@ -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 fetchSongs = async () => {
const res = await fetch('/api/songs'); const res = await fetch('/api/songs', {
headers: getAuthHeaders()
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setSongs(data); setSongs(data);
@@ -160,7 +171,9 @@ export default function AdminPage() {
}; };
const fetchGenres = async () => { const fetchGenres = async () => {
const res = await fetch('/api/genres'); const res = await fetch('/api/genres', {
headers: getAuthHeaders()
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setGenres(data); setGenres(data);
@@ -171,6 +184,7 @@ export default function AdminPage() {
if (!newGenreName.trim()) return; if (!newGenreName.trim()) return;
const res = await fetch('/api/genres', { const res = await fetch('/api/genres', {
method: 'POST', method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ name: newGenreName, subtitle: newGenreSubtitle }), body: JSON.stringify({ name: newGenreName, subtitle: newGenreSubtitle }),
}); });
if (res.ok) { if (res.ok) {
@@ -192,7 +206,7 @@ export default function AdminPage() {
if (editingGenreId === null) return; if (editingGenreId === null) return;
const res = await fetch('/api/genres', { const res = await fetch('/api/genres', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: getAuthHeaders(),
body: JSON.stringify({ body: JSON.stringify({
id: editingGenreId, id: editingGenreId,
name: editGenreName, name: editGenreName,
@@ -209,7 +223,9 @@ export default function AdminPage() {
// Specials functions // Specials functions
const fetchSpecials = async () => { const fetchSpecials = async () => {
const res = await fetch('/api/specials'); const res = await fetch('/api/specials', {
headers: getAuthHeaders()
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setSpecials(data); setSpecials(data);
@@ -220,7 +236,7 @@ export default function AdminPage() {
e.preventDefault(); e.preventDefault();
const res = await fetch('/api/specials', { const res = await fetch('/api/specials', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: getAuthHeaders(),
body: JSON.stringify({ body: JSON.stringify({
name: newSpecialName, name: newSpecialName,
subtitle: newSpecialSubtitle, subtitle: newSpecialSubtitle,
@@ -249,7 +265,7 @@ export default function AdminPage() {
if (!confirm('Delete this special?')) return; if (!confirm('Delete this special?')) return;
const res = await fetch('/api/specials', { const res = await fetch('/api/specials', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: getAuthHeaders(),
body: JSON.stringify({ id }), body: JSON.stringify({ id }),
}); });
if (res.ok) fetchSpecials(); if (res.ok) fetchSpecials();
@@ -258,7 +274,9 @@ export default function AdminPage() {
// Daily Puzzles functions // Daily Puzzles functions
const fetchDailyPuzzles = async () => { const fetchDailyPuzzles = async () => {
const res = await fetch('/api/admin/daily-puzzles'); const res = await fetch('/api/admin/daily-puzzles', {
headers: getAuthHeaders()
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setDailyPuzzles(data); setDailyPuzzles(data);
@@ -269,7 +287,7 @@ export default function AdminPage() {
if (!confirm('Delete this daily puzzle? A new one will be generated automatically.')) return; if (!confirm('Delete this daily puzzle? A new one will be generated automatically.')) return;
const res = await fetch('/api/admin/daily-puzzles', { const res = await fetch('/api/admin/daily-puzzles', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: getAuthHeaders(),
body: JSON.stringify({ puzzleId }), body: JSON.stringify({ puzzleId }),
}); });
if (res.ok) { if (res.ok) {
@@ -328,7 +346,7 @@ export default function AdminPage() {
if (editingSpecialId === null) return; if (editingSpecialId === null) return;
const res = await fetch('/api/specials', { const res = await fetch('/api/specials', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: getAuthHeaders(),
body: JSON.stringify({ body: JSON.stringify({
id: editingSpecialId, id: editingSpecialId,
name: editSpecialName, name: editSpecialName,
@@ -357,6 +375,7 @@ export default function AdminPage() {
if (!confirm('Delete this genre?')) return; if (!confirm('Delete this genre?')) return;
const res = await fetch('/api/genres', { const res = await fetch('/api/genres', {
method: 'DELETE', method: 'DELETE',
headers: getAuthHeaders(),
body: JSON.stringify({ id }), body: JSON.stringify({ id }),
}); });
if (res.ok) { if (res.ok) {
@@ -383,7 +402,7 @@ export default function AdminPage() {
while (hasMore) { while (hasMore) {
const res = await fetch('/api/categorize', { const res = await fetch('/api/categorize', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: getAuthHeaders(),
body: JSON.stringify({ offset }) body: JSON.stringify({ offset })
}); });
@@ -453,6 +472,7 @@ export default function AdminPage() {
const res = await fetch('/api/songs', { const res = await fetch('/api/songs', {
method: 'POST', method: 'POST',
headers: { 'x-admin-auth': localStorage.getItem('hoerdle_admin_auth') || '' },
body: formData, body: formData,
}); });
@@ -555,7 +575,7 @@ export default function AdminPage() {
const res = await fetch('/api/songs', { const res = await fetch('/api/songs', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: getAuthHeaders(),
body: JSON.stringify({ body: JSON.stringify({
id: uploadedSong.id, id: uploadedSong.id,
title: uploadedSong.title, title: uploadedSong.title,
@@ -596,7 +616,7 @@ export default function AdminPage() {
const saveEditing = async (id: number) => { const saveEditing = async (id: number) => {
const res = await fetch('/api/songs', { const res = await fetch('/api/songs', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: getAuthHeaders(),
body: JSON.stringify({ body: JSON.stringify({
id, id,
title: editTitle, title: editTitle,
@@ -623,7 +643,7 @@ export default function AdminPage() {
const res = await fetch('/api/songs', { const res = await fetch('/api/songs', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: getAuthHeaders(),
body: JSON.stringify({ id }), body: JSON.stringify({ id }),
}); });

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -63,6 +64,10 @@ export async function GET() {
} }
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try { try {
const { puzzleId } = await request.json(); const { puzzleId } = await request.json();

View File

@@ -1,7 +1,12 @@
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs'; 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 { try {
const { password } = await request.json(); const { password } = await request.json();
// Default is hash for 'admin123' // Default is hash for 'admin123'

View File

@@ -8,8 +8,28 @@ export async function GET(
) { ) {
try { try {
const { filename } = await params; 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); 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 // Check if file exists
try { try {
await stat(filePath); await stat(filePath);

View File

@@ -1,6 +1,7 @@
'use server'; 'use server';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -16,6 +17,10 @@ interface CategorizeResult {
} }
export async function POST(request: Request) { export async function POST(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try { try {
if (!OPENROUTER_API_KEY) { if (!OPENROUTER_API_KEY) {
return Response.json( return Response.json(

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -21,6 +22,10 @@ export async function GET() {
} }
export async function POST(request: Request) { export async function POST(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try { try {
const { name, subtitle } = await request.json(); const { name, subtitle } = await request.json();
@@ -43,6 +48,10 @@ export async function POST(request: Request) {
} }
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try { try {
const { id } = await request.json(); const { id } = await request.json();
@@ -62,6 +71,10 @@ export async function DELETE(request: Request) {
} }
export async function PUT(request: Request) { export async function PUT(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try { try {
const { id, name, subtitle } = await request.json(); const { id, name, subtitle } = await request.json();

View File

@@ -4,6 +4,7 @@ import { writeFile, unlink } from 'fs/promises';
import path from 'path'; import path from 'path';
import { parseBuffer } from 'music-metadata'; import { parseBuffer } from 'music-metadata';
import { isDuplicateSong } from '@/lib/fuzzyMatch'; import { isDuplicateSong } from '@/lib/fuzzyMatch';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -42,6 +43,10 @@ export async function GET() {
} }
export async function POST(request: Request) { export async function POST(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try { try {
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get('file') as File; 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 }); 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()); const buffer = Buffer.from(await file.arrayBuffer());
// Validate and extract metadata from file // Validate and extract metadata from file
@@ -214,6 +242,10 @@ export async function POST(request: Request) {
} }
export async function PUT(request: Request) { export async function PUT(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try { try {
const { id, title, artist, releaseYear, genreIds, specialIds } = await request.json(); 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) { export async function DELETE(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try { try {
const { id } = await request.json(); const { id } = await request.json();

View File

@@ -1,5 +1,6 @@
import { PrismaClient, Special } from '@prisma/client'; import { PrismaClient, Special } from '@prisma/client';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -16,6 +17,10 @@ export async function GET() {
} }
export async function POST(request: Request) { 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(); const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json();
if (!name) { if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 }); 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) { export async function DELETE(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
const { id } = await request.json(); const { id } = await request.json();
if (!id) { if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 }); return NextResponse.json({ error: 'ID required' }, { status: 400 });
@@ -44,6 +53,10 @@ export async function DELETE(request: Request) {
} }
export async function PUT(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(); const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
if (!id) { if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 }); return NextResponse.json({ error: 'ID required' }, { status: 400 });

27
lib/auth.ts Normal file
View File

@@ -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<NextResponse | null> {
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<boolean> {
const bcrypt = await import('bcryptjs');
const adminPasswordHash = process.env.ADMIN_PASSWORD || '$2b$10$SHOt9G1qUNIvHoWre7499.eEtp5PtOII0daOQGNV.dhDEuPmOUdsq';
return bcrypt.compare(password, adminPasswordHash);
}

76
lib/rateLimit.ts Normal file
View File

@@ -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<string, RateLimitEntry>();
// 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;
}

52
middleware.ts Normal file
View File

@@ -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).*)',
],
};