http.js
1 import cors from "cors"; 2 import crypto from "crypto"; 3 import config from "@/config.js"; 4 import { logger } from "@/middleware/logging.js"; 5 import { sendError, ErrorCodes, StatusCodes, errorResponse } from "@/utils/responses.js"; 6 import { sanitizeObject, safeParseJSON } from "@/utils/sanitizer.js"; 7 import { rateLimiter, authRateLimiter, sensitiveRateLimiter, sizeAwareRateLimiter } from "@/utils/rateLimiters.js"; 8 9 const suspiciousPatterns = config.patterns.suspicious; 10 11 const blockedIps = new Map(); 12 const requestFingerprints = new Map(); 13 const maxFingerprintSize = config.security.auth.fingerprintMaxSize; 14 const fingerprintWindowMs = config.security.http.fingerprintWindowMs; 15 16 const cleanupBlockedIps = () => { 17 const now = Date.now(); 18 for (const [ip, data] of blockedIps.entries()) { 19 if (now > data.expiresAt) { 20 blockedIps.delete(ip); 21 } 22 } 23 }; 24 25 setInterval(cleanupBlockedIps, config.intervals.ipCleanup); 26 27 28 const cleanupFingerprints = () => { 29 const now = Date.now(); 30 for (const [key, value] of requestFingerprints.entries()) { 31 if (now - value.firstSeen > fingerprintWindowMs) { 32 requestFingerprints.delete(key); 33 } 34 } 35 if (requestFingerprints.size > maxFingerprintSize) { 36 const keysToDelete = Array.from(requestFingerprints.keys()).slice(0, 10000); 37 keysToDelete.forEach(k => requestFingerprints.delete(k)); 38 } 39 }; 40 41 setInterval(cleanupFingerprints, config.intervals.fingerprintCleanup); 42 43 const generateFingerprint = (req) => { 44 const components = [ 45 req.ip || "", 46 req.headers["user-agent"] || "", 47 req.headers["accept-language"] || "", 48 req.headers["accept-encoding"] || "", 49 ]; 50 return crypto.createHash("sha256").update(components.join("|")).digest("hex").slice(0, 16); 51 }; 52 53 const isIpBlocked = (ip) => { 54 const blocked = blockedIps.get(ip); 55 if (!blocked) { 56 return false; 57 } else if (Date.now() > blocked.until) { 58 blockedIps.delete(ip); 59 return false; 60 } 61 62 return true; 63 }; 64 65 const blockIp = (ip, durationMs = config.security.http.blockedIpDuration) => { 66 blockedIps.set(ip, { until: Date.now() + durationMs, reason: "suspicious_activity" }); 67 logger.warn("IP blocked", { ip, duration: durationMs }); 68 }; 69 70 const detectSuspiciousRequest = (req) => { 71 const checkString = (str) => { 72 if (!str || typeof str !== "string") { 73 return false; 74 } 75 return suspiciousPatterns.some(pattern => pattern.test(str)); 76 }; 77 78 if (checkString(req.url)) { 79 return true; 80 } else if (checkString(req.originalUrl)) { 81 return true; 82 } 83 84 if (req.query) { 85 for (const value of Object.values(req.query)) { 86 if (checkString(String(value))) { 87 return true; 88 } 89 } 90 } 91 92 if (req.body && typeof req.body === "object") { 93 const checkObject = (obj, depth = 0) => { 94 if (depth > config.security.http.maxRecursionDepth) { 95 return false; 96 } 97 for (const value of Object.values(obj)) { 98 if (typeof value === "string" && checkString(value)) { 99 return true; 100 } else if (typeof value === "object" && value !== null && checkObject(value, depth + 1)) { 101 return true; 102 } 103 } 104 return false; 105 }; 106 if (checkObject(req.body)) { 107 return true; 108 } 109 } 110 111 return false; 112 }; 113 114 const validateOrigin = (origin) => { 115 if (!origin) { 116 return true; 117 } 118 119 const allowedOrigins = config.security.cors.origin.split(",").map(o => o.trim()); 120 if (allowedOrigins.includes("*")) { 121 return true; 122 } 123 124 try { 125 const url = new URL(origin); 126 return allowedOrigins.some(allowed => { 127 if (allowed === origin) { 128 return true; 129 } else if (allowed.startsWith("*.")) { 130 const domain = allowed.slice(2); 131 return url.hostname === domain || url.hostname.endsWith("." + domain); 132 } 133 134 return false; 135 }); 136 } catch { 137 return false; 138 } 139 }; 140 141 export const corsMiddleware = cors({ 142 origin: (origin, callback) => { 143 if (validateOrigin(origin)) { 144 callback(null, true); 145 } else { 146 callback(new Error("Origin not allowed")); 147 } 148 }, 149 credentials: true, 150 methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], 151 allowedHeaders: ["Content-Type", "Authorization", "X-Request-ID"], 152 exposedHeaders: ["Content-Type", "Content-Length", "X-Request-ID"], 153 maxAge: Math.min(config.security.cors.maxAge, 86400), 154 preflightContinue: false, 155 optionsSuccessStatus: 204, 156 }); 157 158 159 export const securityCheckMiddleware = (req, res, next) => { 160 const ip = req.ip || req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || "unknown"; 161 if (isIpBlocked(ip)) { 162 logger.warn("Blocked IP attempted access", { ip, path: req.path }); 163 return sendError(res, ErrorCodes.FORBIDDEN, "Access denied", StatusCodes.FORBIDDEN); 164 } 165 if (detectSuspiciousRequest(req)) { 166 logger.warn("Suspicious request detected", { ip, path: req.path, method: req.method }); 167 const fingerprint = generateFingerprint(req); 168 let tracker = requestFingerprints.get(fingerprint); 169 if (!tracker) { 170 tracker = { count: 0, firstSeen: Date.now() }; 171 requestFingerprints.set(fingerprint, tracker); 172 } 173 tracker.count++; 174 if (tracker.count >= config.security.http.suspiciousRequestThreshold) { 175 blockIp(ip); 176 return sendError(res, ErrorCodes.FORBIDDEN, "Access denied", StatusCodes.FORBIDDEN); 177 } 178 return sendError(res, ErrorCodes.INVALID_INPUT, "Invalid request", StatusCodes.BAD_REQUEST); 179 } 180 next(); 181 }; 182 183 export const requestSizeLimit = (maxSize = 1048576) => { 184 return (req, res, next) => { 185 const contentLength = parseInt(req.headers["content-length"] || "0", 10); 186 if (contentLength > maxSize) { 187 return sendError(res, ErrorCodes.PAYLOAD_TOO_LARGE, "Request too large", StatusCodes.REQUEST_TOO_LARGE); 188 } 189 next(); 190 }; 191 }; 192 193 export const sanitizeInput = (req, res, next) => { 194 if (req.body) { 195 if (typeof req.body === "string") { 196 req.body = safeParseJSON(req.body); 197 } else { 198 req.body = sanitizeObject(req.body); 199 } 200 } 201 if (req.query) { 202 const sanitizedQuery = sanitizeObject(req.query); 203 for (const key in sanitizedQuery) { 204 req.query[key] = sanitizedQuery[key]; 205 } 206 } 207 if (req.params) { 208 const sanitizedParams = sanitizeObject(req.params); 209 for (const key in sanitizedParams) { 210 req.params[key] = sanitizedParams[key]; 211 } 212 } 213 next(); 214 }; 215 216 217 export const addSecurityHeaders = (req, res, next) => { 218 res.setHeader("X-Request-ID", crypto.randomBytes(16).toString("hex")); 219 res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate"); 220 res.setHeader("Pragma", "no-cache"); 221 res.setHeader("Expires", "0"); 222 res.setHeader("Surrogate-Control", "no-store"); 223 res.removeHeader("X-Powered-By"); 224 next(); 225 };