diff --git a/lib/rate-limiter.ts b/lib/rate-limiter.ts new file mode 100644 index 0000000..da253fe --- /dev/null +++ b/lib/rate-limiter.ts @@ -0,0 +1,61 @@ +interface RateLimitData { + count: number + resetTime: number +} + +const cache = new Map() + +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) { + return { + 'X-RateLimit-Limit': '100', + 'X-RateLimit-Remaining': result.remaining.toString(), + 'X-RateLimit-Reset': new Date(result.resetTime).toISOString() + } +} \ No newline at end of file diff --git a/lib/validate-input.ts b/lib/validate-input.ts new file mode 100644 index 0000000..a6e4af3 --- /dev/null +++ b/lib/validate-input.ts @@ -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 + } +} \ No newline at end of file diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..347f6ce --- /dev/null +++ b/middleware.ts @@ -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' + ] +} \ No newline at end of file diff --git a/pages/api/sources/check.ts b/pages/api/sources/check.ts index 3ed7bb3..41055e0 100644 --- a/pages/api/sources/check.ts +++ b/pages/api/sources/check.ts @@ -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') {