const rateLimit = require('express-rate-limit') const log = require('../utils/logger')('ratelimit') function makeLimiter({ windowMs, max, label, message, keyGenerator, validate }) { return rateLimit({ windowMs, max, standardHeaders: true, legacyHeaders: false, message: { message }, // Default key is the client IP; callers can widen it (e.g. IP + provider). ...(keyGenerator ? { keyGenerator } : {}), // Custom keyGenerators that fold in req.ip trip v7's IPv6 fallback validator; // callers pass `validate` to scope that off just for their limiter. ...(validate !== undefined ? { validate } : {}), handler: (req, res, next, options) => { log.warn(`${label} rate limit exceeded`, { ip: req.ip, path: req.originalUrl }) res.status(options.statusCode).json(options.message) }, }) } // Brute-force protection on login. const loginLimiter = makeLimiter({ windowMs: 15 * 60 * 1000, max: 10, label: 'login', message: 'Too many login attempts. Please try again later.', }) // Public self-registration. Mirrors the login cap: a handful of legitimate // attempts per window, a flood is abuse. The global botScore guard + honeypot // cover the rest. const registerLimiter = makeLimiter({ windowMs: 15 * 60 * 1000, max: 10, label: 'register', message: 'Too many registration attempts. Please try again later.', }) // Authenticated self-service credential changes (username / password). Tighter // than login — a signed-in player rarely changes these, and the wrong-current- // password path also feeds the shared login backoff (see the controller). const accountChangeLimiter = makeLimiter({ windowMs: 15 * 60 * 1000, max: 10, label: 'account-change', message: 'Too many changes. Please try again later.', }) // Throttle the public contact form. const contactLimiter = makeLimiter({ windowMs: 60 * 60 * 1000, max: 5, label: 'contact', message: 'Too many messages sent. Please try again later.', }) // Cap mobile refresh-token exchanges per IP. Legitimate apps refresh at most a // handful of times per window; a flood is either a bug or an attempt to brute // the refresh endpoint. const mobileRefreshLimiter = makeLimiter({ windowMs: 15 * 60 * 1000, max: 30, label: 'mobile-refresh', message: 'Too many refresh attempts. Please try again later.', }) // Throttle SSO redirect starts per IP — cheap to trigger, and a flood is either a // bug or an attempt to spin the OAuth flow. Generous enough for real users. const ssoStartLimiter = makeLimiter({ windowMs: 15 * 60 * 1000, max: 30, label: 'sso-start', message: 'Too many sign-in attempts. Please try again later.', }) // Mobile SSO bridge — throttle /start per IP AND per provider: each call spawns a // mobile_auth_sessions row, so without a per-provider dimension /start is a cheap // way to spam rows for one provider from many-but-few IPs. Generous for real users // (a login is a handful of taps). `validate:{ip:false}` scopes off v7's IPv6 // fallback check, which fires only because our key folds in req.ip. const mobileSsoStartLimiter = makeLimiter({ windowMs: 15 * 60 * 1000, max: 20, label: 'mobile-sso-start', message: 'Too many sign-in attempts. Please try again later.', keyGenerator: (req) => `${req.ip}:${req.query && req.query.provider ? req.query.provider : ''}`, validate: { ip: false }, }) // Mobile SSO bridge — throttle /exchange per IP. The code is single-use, PKCE-bound // and short-lived, but cap redemption attempts anyway to blunt guessing. const mobileSsoExchangeLimiter = makeLimiter({ windowMs: 15 * 60 * 1000, max: 30, label: 'mobile-sso-exchange', message: 'Too many attempts. Please try again later.', }) // Password-reset requests per IP. Each one can send email, so cap tighter than // login to blunt email-bombing and enumeration timing probes. The endpoint always // returns a generic success regardless of match, so honest users never see this. const passwordResetRequestLimiter = makeLimiter({ windowMs: 60 * 60 * 1000, max: 5, label: 'password-reset-request', message: 'Too many reset requests. Please try again later.', }) // Reset confirmations (token + new password) per IP. A wrong/expired token is a // guessing surface; the token itself is 256-bit random, but cap anyway. const passwordResetConfirmLimiter = makeLimiter({ windowMs: 15 * 60 * 1000, max: 15, label: 'password-reset-confirm', message: 'Too many attempts. Please try again later.', }) // The player-vendor market search. The first genuinely expensive PUBLIC endpoint // on the site: every call is a LIKE scan plus a COUNT over the listings table, // which on a large shard is the biggest table there is, and it is anonymous by // default. Generous for a human browsing shops (a typed search is debounced to // one request, and paging is a click), tight enough that it cannot be used as a // cheap way to load the database. const marketLimiter = makeLimiter({ windowMs: 60 * 1000, max: 60, label: 'market', message: 'Too many searches. Please slow down.', }) // CSP violation reports. Unauthenticated by necessity (browsers send them with no // session), and every accepted report writes a log line — so an attacker who can get // a victim to load a page could otherwise use it as a log-flood amplifier. Generous // enough for the real case: a genuinely broken directive fires a handful of times per // page load, and browsers already de-duplicate identical violations per document. const cspReportLimiter = makeLimiter({ windowMs: 5 * 60 * 1000, max: 60, label: 'csp-report', message: 'Too many reports.', }) module.exports = { loginLimiter, registerLimiter, accountChangeLimiter, contactLimiter, mobileRefreshLimiter, ssoStartLimiter, mobileSsoStartLimiter, mobileSsoExchangeLimiter, passwordResetRequestLimiter, passwordResetConfirmLimiter, marketLimiter, cspReportLimiter, }