Merge security-audit-improvements: comprehensive security enhancements
This commit is contained in:
@@ -151,8 +151,30 @@ export default function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('hoerdle_admin_auth');
|
||||
setIsAuthenticated(false);
|
||||
setPassword('');
|
||||
// Reset all state
|
||||
setSongs([]);
|
||||
setGenres([]);
|
||||
setSpecials([]);
|
||||
setDailyPuzzles([]);
|
||||
};
|
||||
|
||||
// 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 +182,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 +195,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 +217,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 +234,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 +247,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 +276,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 +285,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 +298,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 +357,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 +386,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 +413,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 +483,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 +586,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 +627,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 +654,7 @@ export default function AdminPage() {
|
||||
|
||||
const res = await fetch('/api/songs', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
|
||||
@@ -759,7 +790,24 @@ export default function AdminPage() {
|
||||
|
||||
return (
|
||||
<div className="admin-container">
|
||||
<h1 className="title" style={{ marginBottom: '2rem' }}>Hördle Admin Dashboard</h1>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<h1 className="title" style={{ margin: 0 }}>Hördle Admin Dashboard</h1>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="btn-secondary"
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
>
|
||||
🚪 Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Special Management */}
|
||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
37
lib/auth.ts
Normal file
37
lib/auth.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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');
|
||||
|
||||
// Validate that ADMIN_PASSWORD is set (security best practice)
|
||||
if (!process.env.ADMIN_PASSWORD) {
|
||||
console.error('SECURITY WARNING: ADMIN_PASSWORD environment variable is not set!');
|
||||
// Fallback to default hash only in development
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('ADMIN_PASSWORD environment variable is required in production');
|
||||
}
|
||||
}
|
||||
|
||||
const adminPasswordHash = process.env.ADMIN_PASSWORD || '$2b$10$SHOt9G1qUNIvHoWre7499.eEtp5PtOII0daOQGNV.dhDEuPmOUdsq';
|
||||
return bcrypt.compare(password, adminPasswordHash);
|
||||
}
|
||||
76
lib/rateLimit.ts
Normal file
76
lib/rateLimit.ts
Normal 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
52
middleware.ts
Normal 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).*)',
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user