/ src / middleware / http.js
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  };