rate limiting and api security enhancements
This commit is contained in:
61
lib/rate-limiter.ts
Normal file
61
lib/rate-limiter.ts
Normal 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
80
lib/validate-input.ts
Normal 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
40
middleware.ts
Normal 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'
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import sqlite3 from 'sqlite3'
|
import sqlite3 from 'sqlite3'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { rateLimit, getRateLimitHeaders } from '../../../lib/rate-limiter'
|
||||||
|
|
||||||
type CheckResponse = {
|
type CheckResponse = {
|
||||||
is_problematic: boolean
|
is_problematic: boolean
|
||||||
@@ -54,6 +55,19 @@ export default async function handler(
|
|||||||
return res.status(405).json({ error: 'Method not allowed' })
|
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
|
const { url } = req.query
|
||||||
|
|
||||||
if (!url || typeof url !== 'string') {
|
if (!url || typeof url !== 'string') {
|
||||||
|
|||||||
Reference in New Issue
Block a user