- Update Content Security Policy (CSP) in next.config.ts to avoid `unsafe-eval` in production, improving security against XSS attacks. - Refactor API routes to enforce admin authentication and session validation, ensuring secure access to sensitive endpoints. - Optimize analytics data retrieval by using database aggregation instead of loading all records into memory, improving performance and reducing memory usage. - Implement session token creation and verification for better session management and security across the application. - Enhance error handling and input validation in various API routes to ensure robustness and prevent potential issues.
228 lines
7.0 KiB
TypeScript
228 lines
7.0 KiB
TypeScript
import { NextRequest } from 'next/server';
|
|
import crypto from 'crypto';
|
|
|
|
const DEFAULT_INSECURE_ADMIN = 'admin:default_password_change_me';
|
|
const SESSION_DURATION_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
|
|
function base64UrlEncode(input: string | Buffer): string {
|
|
const buf = typeof input === 'string' ? Buffer.from(input, 'utf8') : input;
|
|
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
}
|
|
|
|
function base64UrlDecodeToString(input: string): string {
|
|
const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
|
|
const pad = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
|
|
return Buffer.from(normalized + pad, 'base64').toString('utf8');
|
|
}
|
|
|
|
function base64UrlDecodeToBuffer(input: string): Buffer {
|
|
const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
|
|
const pad = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
|
|
return Buffer.from(normalized + pad, 'base64');
|
|
}
|
|
|
|
export function getClientIp(request: NextRequest): string {
|
|
const xff = request.headers.get('x-forwarded-for');
|
|
if (xff) {
|
|
// x-forwarded-for can be a list: client, proxy1, proxy2
|
|
return xff.split(',')[0]?.trim() || 'unknown';
|
|
}
|
|
return request.headers.get('x-real-ip') || 'unknown';
|
|
}
|
|
|
|
function getAdminCredentials(): { username: string; password: string } | null {
|
|
const raw = process.env.ADMIN_BASIC_AUTH;
|
|
if (!raw || raw.trim() === '' || raw === DEFAULT_INSECURE_ADMIN) return null;
|
|
const idx = raw.indexOf(':');
|
|
if (idx <= 0 || idx === raw.length - 1) return null;
|
|
return { username: raw.slice(0, idx), password: raw.slice(idx + 1) };
|
|
}
|
|
|
|
function getSessionSecret(): string | null {
|
|
const secret = process.env.ADMIN_SESSION_SECRET;
|
|
if (!secret || secret.trim().length < 32) return null; // require a reasonably strong secret
|
|
return secret;
|
|
}
|
|
|
|
type SessionPayload = {
|
|
v: 1;
|
|
iat: number;
|
|
rnd: string;
|
|
ip: string;
|
|
ua: string;
|
|
};
|
|
|
|
export function createSessionToken(request: NextRequest): string | null {
|
|
const secret = getSessionSecret();
|
|
if (!secret) return null;
|
|
|
|
const payload: SessionPayload = {
|
|
v: 1,
|
|
iat: Date.now(),
|
|
rnd: crypto.randomBytes(32).toString('hex'),
|
|
ip: getClientIp(request),
|
|
ua: request.headers.get('user-agent') || 'unknown',
|
|
};
|
|
|
|
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
|
const sig = crypto.createHmac('sha256', secret).update(payloadB64).digest();
|
|
const sigB64 = base64UrlEncode(sig);
|
|
return `${payloadB64}.${sigB64}`;
|
|
}
|
|
|
|
export function verifySessionToken(request: NextRequest, token: string): boolean {
|
|
const secret = getSessionSecret();
|
|
if (!secret) return false;
|
|
|
|
const parts = token.split('.');
|
|
if (parts.length !== 2) return false;
|
|
const [payloadB64, sigB64] = parts;
|
|
if (!payloadB64 || !sigB64) return false;
|
|
|
|
let providedSigBytes: Buffer;
|
|
try {
|
|
providedSigBytes = base64UrlDecodeToBuffer(sigB64);
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
const expectedSigBytes = crypto.createHmac('sha256', secret).update(payloadB64).digest();
|
|
if (providedSigBytes.length !== expectedSigBytes.length) return false;
|
|
if (!crypto.timingSafeEqual(providedSigBytes, expectedSigBytes)) return false;
|
|
|
|
let payload: SessionPayload;
|
|
try {
|
|
payload = JSON.parse(base64UrlDecodeToString(payloadB64)) as SessionPayload;
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
if (!payload || payload.v !== 1 || typeof payload.iat !== 'number' || typeof payload.rnd !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (now - payload.iat > SESSION_DURATION_MS) return false;
|
|
|
|
// Bind token to client IP + UA (best-effort; "unknown" should not hard-fail)
|
|
const currentIp = getClientIp(request);
|
|
const currentUa = request.headers.get('user-agent') || 'unknown';
|
|
if (payload.ip !== 'unknown' && currentIp !== 'unknown' && payload.ip !== currentIp) return false;
|
|
if (payload.ua !== 'unknown' && currentUa !== 'unknown' && payload.ua !== currentUa) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
// Server-side authentication utilities
|
|
export function verifyAdminAuth(request: NextRequest): boolean {
|
|
// Check for basic auth header
|
|
const authHeader = request.headers.get('authorization');
|
|
|
|
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const base64Credentials = authHeader.split(' ')[1];
|
|
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf8');
|
|
const [username, password] = credentials.split(':');
|
|
|
|
// Get admin credentials from environment
|
|
const creds = getAdminCredentials();
|
|
if (!creds) return false;
|
|
|
|
return username === creds.username && password === creds.password;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function requireAdminAuth(request: NextRequest): Response | null {
|
|
if (!verifyAdminAuth(request)) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Unauthorized' }),
|
|
{
|
|
status: 401,
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
}
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Session-based authentication (no browser popup)
|
|
export function verifySessionAuth(request: NextRequest): boolean {
|
|
// Check for session token in headers
|
|
const sessionToken = request.headers.get('x-session-token');
|
|
if (!sessionToken) return false;
|
|
|
|
try {
|
|
return verifySessionToken(request, sessionToken);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function requireSessionAuth(request: NextRequest): Response | null {
|
|
if (!verifySessionAuth(request)) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Session expired or invalid' }),
|
|
{
|
|
status: 401,
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
}
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Rate limiting for admin endpoints
|
|
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();
|
|
|
|
// Clear rate limit cache on startup
|
|
if (typeof window === 'undefined') {
|
|
// Server-side: clear cache periodically
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, value] of rateLimitMap.entries()) {
|
|
if (now > value.resetTime) {
|
|
rateLimitMap.delete(key);
|
|
}
|
|
}
|
|
}, 60000); // Clear every minute
|
|
}
|
|
|
|
export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean {
|
|
const now = Date.now();
|
|
const key = `admin_${ip}`;
|
|
|
|
const current = rateLimitMap.get(key);
|
|
|
|
if (!current || now > current.resetTime) {
|
|
rateLimitMap.set(key, { count: 1, resetTime: now + windowMs });
|
|
return true;
|
|
}
|
|
|
|
if (current.count >= maxRequests) {
|
|
return false;
|
|
}
|
|
|
|
current.count++;
|
|
return true;
|
|
}
|
|
|
|
export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000): Record<string, string> {
|
|
const current = rateLimitMap.get(`admin_${ip}`);
|
|
const remaining = current ? Math.max(0, maxRequests - current.count) : maxRequests;
|
|
|
|
return {
|
|
'X-RateLimit-Limit': maxRequests.toString(),
|
|
'X-RateLimit-Remaining': remaining.toString(),
|
|
'X-RateLimit-Reset': current ? Math.ceil(current.resetTime / 1000).toString() : Math.ceil((Date.now() + windowMs) / 1000).toString()
|
|
};
|
|
}
|