-
Notifications
You must be signed in to change notification settings - Fork 43
feat(security): implement comprehensive rate limiting across API endpoints #50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,7 +18,14 @@ import feedbackRoutes from "./routes/feedback.js"; | |
| import pdfChatRoutes from "./routes/pdfChat.js"; | ||
| import sitemapRoutes from "./routes/sitemap.js"; | ||
| import { setupSocketHandlers } from "./socket/socketHandlers.js"; | ||
| import initRedis from "./utils/redis.js"; | ||
| import { | ||
| generalLimiter, | ||
| authLimiter, | ||
| uploadLimiter, | ||
| discussionLimiter, | ||
| chatLimiter, | ||
| strictAuthLimiter, | ||
| } from "./middleware/rateLimiter.js"; | ||
|
|
||
| BigInt.prototype.toJSON = function () { | ||
| return this.toString(); | ||
|
|
@@ -27,14 +34,6 @@ BigInt.prototype.toJSON = function () { | |
| // Load environment variables | ||
| dotenv.config(); | ||
|
|
||
| // Initialize Redis (optional - will work without it) | ||
| initRedis().catch((err) => { | ||
| console.log( | ||
| "⚠️ Redis not available, continuing without cache:", | ||
| err.message | ||
| ); | ||
| }); | ||
|
|
||
| const app = express(); | ||
| const server = createServer(app); | ||
|
|
||
|
|
@@ -78,6 +77,21 @@ app.use(express.urlencoded({ extended: true, limit: "50mb" })); | |
| // Initialize Passport | ||
| app.use(passport.initialize()); | ||
|
|
||
| // Rate Limiting Middleware | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Apply the general limiter only to specific high-traffic endpoints or route groups (e.g., performance |
||
| app.use("/api/", generalLimiter); | ||
| app.use("/api/auth/login", authLimiter); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Document the skip rationale (which endpoints should bypass) and verify that it matches the actual signup route path. If signup is not actually under a path containing "signup", update the skip logic or add documentation clarifying the intended route. documentation |
||
| app.use("/api/auth/send-otp", authLimiter); | ||
| app.use("/api/auth/forgot-password", strictAuthLimiter); | ||
| app.use("/api/auth/reset-password", strictAuthLimiter); | ||
| app.use("/api/upload", uploadLimiter); | ||
| app.use("/api/discussions", (req, res, next) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Use the limiter as middleware directly without re-wrapping when possible, e.g. best-practices There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Medium — For discussions, rate limiting middleware is conditionally invoked only for Confirm actual HTTP method(s) used for “posting” in bugs There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Medium — The discussions limiter is wired as Replace with a method check without array allocation: performance |
||
| if (["POST"].includes(req.method)) { | ||
| return discussionLimiter(req, res, next); | ||
| } | ||
| next(); | ||
| }); | ||
| app.use("/api/pdf-chat", chatLimiter); | ||
|
|
||
| // Debug middleware to log all requests | ||
| app.use((req, res, next) => { | ||
| console.log(`🔍 ${req.method} ${req.path}`); | ||
|
|
@@ -107,7 +121,7 @@ app.use("/", sitemapRoutes); | |
| // Setup Socket.IO handlers | ||
| setupSocketHandlers(io); | ||
|
|
||
| // Health check endpoint | ||
| // Health check endpoint (not rate limited) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Medium — Health check endpoint Do not skip security |
||
| app.get("/api/health", async (req, res) => { | ||
| try { | ||
| // Test database connection | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| import rateLimit from "express-rate-limit"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add JSDoc blocks for each exported limiter (or a shared typedef) including: what route patterns they’re intended for, required authentication state (e.g., relies on req.user.id), keying strategy, and the exact 429 JSON response fields (error/retryAfter). For createCustomLimiter, document options, defaults, and return type (Express middleware/rateLimit handler). documentation |
||
|
|
||
| // General API Rate Limit: 500 requests per 15 minutes per IP | ||
| export const generalLimiter = rateLimit({ | ||
| windowMs: 15 * 60 * 1000, | ||
| max: 500, | ||
| message: { | ||
| error: "Too many requests from this IP, please try again later.", | ||
| retryAfter: "15 minutes", | ||
| }, | ||
| standardHeaders: true, | ||
| legacyHeaders: false, | ||
| skip: (req) => req.path === "/api/health", | ||
| keyGenerator: (req) => | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Harden IP extraction by relying on Express’s bugs There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Use Express best-practices There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Medium — Rate limiter keying uses untrusted client-controlled header Use security There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Medium — Avoid performance |
||
| req.headers["x-forwarded-for"]?.split(",")[0] || | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Trim the selected value and consider using Express readability There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Medium — IP parsing picks Trim the parsed segment: bugs |
||
| req.socket.remoteAddress || | ||
| "unknown", | ||
| }); | ||
|
|
||
| // Authentication Rate Limit: 5 login attempts per 15 minutes per IP | ||
| export const authLimiter = rateLimit({ | ||
| windowMs: 15 * 60 * 1000, | ||
| max: 5, | ||
| message: { | ||
| error: "Too many login attempts, please try again after 15 minutes.", | ||
| retryAfter: "15 minutes", | ||
| }, | ||
| standardHeaders: true, | ||
| legacyHeaders: false, | ||
| skip: (req) => req.path.includes("signup") && req.method === "POST", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Medium — Remove the security There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Medium — Use exact route matching (e.g., performance |
||
| keyGenerator: (req) => | ||
| req.headers["x-forwarded-for"]?.split(",")[0] || | ||
| req.socket.remoteAddress || | ||
| "unknown", | ||
| }); | ||
|
|
||
| // File Upload Rate Limit: 10 uploads per hour per authenticated user | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Document that uploadLimiter expects authentication middleware to have set req.user (and what shape is expected: req.user.id). If unauthenticated uploads are possible, document the intended fallback behavior (IP-based). documentation |
||
| export const uploadLimiter = rateLimit({ | ||
| windowMs: 60 * 60 * 1000, | ||
| max: 10, | ||
| message: { | ||
| error: "Upload limit exceeded. Maximum 10 files per hour.", | ||
| retryAfter: "1 hour", | ||
| }, | ||
| standardHeaders: true, | ||
| legacyHeaders: false, | ||
| keyGenerator: (req) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Use best-practices There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Medium — For Set performance |
||
| if (req.user && req.user.id) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 Critical — In Defensively coerce/validate email: bugs |
||
| return `upload_${req.user.id}`; | ||
| } | ||
| return `upload_${ | ||
| req.headers["x-forwarded-for"]?.split(",")[0] || | ||
| req.socket.remoteAddress || | ||
| "unknown" | ||
| }`; | ||
| }, | ||
| handler: (req, res) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Standardize response shape across all limiters or document the differences. Ensure documentation states the exact JSON body for 429 responses for each limiter. documentation |
||
| res.status(429).json({ | ||
| error: "Upload limit exceeded. Maximum 10 files per hour.", | ||
| retryAfter: "1 hour", | ||
| }); | ||
| }, | ||
| }); | ||
|
|
||
| // Discussion/Comment Rate Limit: 20 posts per hour per authenticated user | ||
| export const discussionLimiter = rateLimit({ | ||
| windowMs: 60 * 60 * 1000, | ||
| max: 20, | ||
| message: { | ||
| error: "Discussion posting limit exceeded. Maximum 20 posts per hour.", | ||
| retryAfter: "1 hour", | ||
| }, | ||
| standardHeaders: true, | ||
| legacyHeaders: false, | ||
| keyGenerator: (req) => { | ||
| if (req.user && req.user.id) { | ||
| return `discussion_${req.user.id}`; | ||
| } | ||
| return `discussion_${ | ||
| req.headers["x-forwarded-for"]?.split(",")[0] || | ||
| req.socket.remoteAddress || | ||
| "unknown" | ||
| }`; | ||
| }, | ||
| handler: (req, res) => { | ||
| res.status(429).json({ | ||
| error: "Discussion posting limit exceeded. Maximum 20 posts per hour.", | ||
| retryAfter: "1 hour", | ||
| }); | ||
| }, | ||
| }); | ||
|
|
||
| // AI Chat Rate Limit: 30 requests per hour per authenticated user | ||
| export const chatLimiter = rateLimit({ | ||
| windowMs: 60 * 60 * 1000, | ||
| max: 30, | ||
| message: { | ||
| error: "Chat limit exceeded. Maximum 30 messages per hour.", | ||
| retryAfter: "1 hour", | ||
| }, | ||
| standardHeaders: true, | ||
| legacyHeaders: false, | ||
| keyGenerator: (req) => { | ||
| if (req.user && req.user.id) { | ||
| return `chat_${req.user.id}`; | ||
| } | ||
| return `chat_${ | ||
| req.headers["x-forwarded-for"]?.split(",")[0] || | ||
| req.socket.remoteAddress || | ||
| "unknown" | ||
| }`; | ||
| }, | ||
| handler: (req, res) => { | ||
| res.status(429).json({ | ||
| error: "Chat limit exceeded. Maximum 30 messages per hour.", | ||
| retryAfter: "1 hour", | ||
| }); | ||
| }, | ||
| }); | ||
|
|
||
| // Password Reset Rate Limit: 3 attempts per hour per email/IP | ||
| export const strictAuthLimiter = rateLimit({ | ||
| windowMs: 60 * 60 * 1000, | ||
| max: 3, | ||
| message: { | ||
| error: "Too many password reset attempts. Please try again after 1 hour.", | ||
| retryAfter: "1 hour", | ||
| }, | ||
| standardHeaders: true, | ||
| legacyHeaders: false, | ||
| keyGenerator: (req) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Guard more defensively: ensure readability |
||
| const email = req.body?.email || req.query?.email; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 Critical — Guard with type checks and normalize: `const emailVal = ...; const emailStr = Array.isArray(emailVal) ? emailVal[0] : emailVal; if (typeof emailStr === 'string') ... else fallback to IP key. bugs There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Validate and normalize email before using it in a key (e.g., strict regex for RFC5322-lite, trim whitespace, cap length). If invalid/missing, fall back to IP-based keying rather than using attacker-controlled raw input. security There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Ensure performance |
||
| if (email) { | ||
| return `passwordreset_${email.toLowerCase()}`; | ||
| } | ||
| return `passwordreset_${ | ||
| req.headers["x-forwarded-for"]?.split(",")[0] || | ||
| req.socket.remoteAddress || | ||
| "unknown" | ||
| }`; | ||
| }, | ||
| handler: (req, res) => { | ||
| res.status(429).json({ | ||
| error: "Too many password reset attempts. Please try again after 1 hour.", | ||
| retryAfter: "1 hour", | ||
| }); | ||
| }, | ||
| }); | ||
|
|
||
| // Custom rate limiter factory for fine-grained control | ||
| export const createCustomLimiter = (options = {}) => { | ||
| const defaults = { | ||
| windowMs: 15 * 60 * 1000, | ||
| max: 500, | ||
| standardHeaders: true, | ||
| legacyHeaders: false, | ||
| }; | ||
| return rateLimit({ ...defaults, ...options }); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
app.use("/api/", generalLimiter)before the health route (app.get("/api/health", ...)) but the general limiter skip only checksreq.path === "/api/health". Depending on Express mounting,req.pathfor requests may not include the/apiprefix (it can be/healthwhen the middleware is mounted on/api/). If the skip doesn’t match,/api/healthmay get rate-limited unintentionally.Ensure skip logic matches the effective path for the mounted middleware. For example, use
req.originalUrl(req.originalUrl === "/api/health") or configure the middleware on/apiand skip usingreq.path === "/health".bugs