rate limiting and api security enhancements

This commit is contained in:
2024-09-10 13:47:16 +02:00
parent 88991c9de0
commit 158a8ca0bb
4 changed files with 195 additions and 0 deletions

61
lib/rate-limiter.ts Normal file
View File

@@ -0,0 +1,61 @@
interface RateLimitData {
count: number
resetTime: number
}
const cache = new Map<string, RateLimitData>()
export interface RateLimitConfig {
windowMs: number
maxRequests: number
}
const defaultConfig: RateLimitConfig = {
windowMs: 60 * 1000, // 1 minute
maxRequests: 100
}
export function rateLimit(
identifier: string,
config: RateLimitConfig = defaultConfig
): { allowed: boolean; remaining: number; resetTime: number } {
const now = Date.now()
const windowStart = now - config.windowMs
// Clean expired entries
for (const [key, data] of cache.entries()) {
if (data.resetTime < now) {
cache.delete(key)
}
}
let data = cache.get(identifier)
if (!data || data.resetTime < now) {
data = {
count: 0,
resetTime: now + config.windowMs
}
}
data.count++
cache.set(identifier, data)
const allowed = data.count <= config.maxRequests
const remaining = Math.max(0, config.maxRequests - data.count)
return {
allowed,
remaining,
resetTime: data.resetTime
}
}
export function getRateLimitHeaders(result: ReturnType<typeof rateLimit>) {
return {
'X-RateLimit-Limit': '100',
'X-RateLimit-Remaining': result.remaining.toString(),
'X-RateLimit-Reset': new Date(result.resetTime).toISOString()
}
}

80
lib/validate-input.ts Normal file
View File

@@ -0,0 +1,80 @@
export function sanitizeUrl(url: string): string {
return url
.trim()
.replace(/[<>'"]/g, '') // Remove potential XSS characters
.substring(0, 2048) // Limit length
}
export function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email) && email.length <= 254
}
export function validateUrl(url: string): boolean {
try {
const sanitized = sanitizeUrl(url)
if (!sanitized) return false
// Add protocol if missing
let testUrl = sanitized
if (!testUrl.startsWith('http://') && !testUrl.startsWith('https://')) {
testUrl = 'https://' + testUrl
}
const urlObj = new URL(testUrl)
// Check for suspicious patterns
const suspiciousPatterns = [
/javascript:/i,
/data:/i,
/vbscript:/i,
/file:/i,
/about:/i
]
return !suspiciousPatterns.some(pattern => pattern.test(testUrl))
} catch {
return false
}
}
export function validateRiskLevel(level: any): boolean {
const num = parseInt(level)
return !isNaN(num) && num >= 1 && num <= 5
}
export function escapeSql(input: string): string {
return input.replace(/'/g, "''")
}
export interface ValidationResult {
valid: boolean
errors: string[]
}
export function validateReportData(data: any): ValidationResult {
const errors: string[] = []
if (!data.source_url || typeof data.source_url !== 'string') {
errors.push('Source URL is required')
} else if (!validateUrl(data.source_url)) {
errors.push('Invalid URL format')
}
if (data.reporter_email && !validateEmail(data.reporter_email)) {
errors.push('Invalid email format')
}
if (!data.categories || !Array.isArray(data.categories) || data.categories.length === 0) {
errors.push('At least one category is required')
}
if (data.description && typeof data.description === 'string' && data.description.length > 1000) {
errors.push('Description too long (max 1000 characters)')
}
return {
valid: errors.length === 0,
errors
}
}

40
middleware.ts Normal file
View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
const response = NextResponse.next()
// CORS headers for browser extensions
if (request.method === 'OPTIONS') {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key',
'Access-Control-Max-Age': '86400'
}
})
}
// Set CORS headers for actual requests
response.headers.set('Access-Control-Allow-Origin', '*')
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
// Security headers
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-XSS-Protection', '1; mode=block')
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
return response
}
export const config = {
matcher: [
'/api/sources/:path*',
'/api/stats',
'/api/domains/:path*',
'/api/reports'
]
}

View File

@@ -1,6 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import sqlite3 from 'sqlite3'
import path from 'path'
import { rateLimit, getRateLimitHeaders } from '../../../lib/rate-limiter'
type CheckResponse = {
is_problematic: boolean
@@ -54,6 +55,19 @@ export default async function handler(
return res.status(405).json({ error: 'Method not allowed' })
}
// Rate limiting
const clientIp = req.headers['x-forwarded-for'] || req.connection?.remoteAddress || 'unknown'
const rateLimitResult = rateLimit(clientIp.toString())
const headers = getRateLimitHeaders(rateLimitResult)
Object.entries(headers).forEach(([key, value]) => {
res.setHeader(key, value)
})
if (!rateLimitResult.allowed) {
return res.status(429).json({ error: 'Too many requests' })
}
const { url } = req.query
if (!url || typeof url !== 'string') {