Makes `users.email` unique, de-duplicates the addresses an upgrade will find, and builds the self-service change-and-verify flow that did not exist. The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED` column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan specified. Every case-insensitive collation this server offers is also accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are two different mailboxes. The plan's index would have refused the second address forever and the de-duplication would have nulled a legitimate account's. A requested address is STAGED in `email_pending` and only a tokened link installs it, so a typo cannot silently redirect account-recovery mail. `isDuplicateUsername()` now distinguishes the two indexes. All five call sites branch on it; each answers differently on purpose, because a public form, an IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth. SSO reads the IdP's actual `email_verified`/`verified` claim instead of inferring verification from an address merely being present. Co-Authored-By: Claude <noreply@anthropic.com>
168 lines
6.3 KiB
JavaScript
168 lines
6.3 KiB
JavaScript
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.',
|
|
})
|
|
|
|
// Email-verification confirmations (engagement Phase 1b). Same reasoning as the
|
|
// password-reset confirm limiter: the token is 256-bit random, but an
|
|
// unauthenticated token-bearing endpoint should not be free to hammer. The
|
|
// REQUEST side is authenticated and limited separately — accountChangeLimiter per
|
|
// IP, plus a per-user ceiling in the model, because the mail goes to an address
|
|
// its recipient did not ask to hear from.
|
|
const emailVerifyConfirmLimiter = makeLimiter({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 15,
|
|
label: 'email-verify-confirm',
|
|
message: 'Too many attempts. Please try again later.',
|
|
})
|
|
|
|
// 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 = {
|
|
// Exported for modules (MODULE_API.md 2.3, added in API 1.1.0). A module
|
|
// writes its own policy -- the window and the cap are its business, since it
|
|
// knows what its endpoints cost -- but it takes the PLUMBING from here: one
|
|
// express-rate-limit in the process, one store, and one place limit breaches
|
|
// are logged. A module resolving the package itself would get a second store,
|
|
// and a limit enforced by two independent counters is not the limit either of
|
|
// them states.
|
|
makeLimiter,
|
|
loginLimiter,
|
|
registerLimiter,
|
|
accountChangeLimiter,
|
|
contactLimiter,
|
|
mobileRefreshLimiter,
|
|
ssoStartLimiter,
|
|
mobileSsoStartLimiter,
|
|
mobileSsoExchangeLimiter,
|
|
passwordResetRequestLimiter,
|
|
passwordResetConfirmLimiter,
|
|
emailVerifyConfirmLimiter,
|
|
cspReportLimiter,
|
|
}
|