Everything the extraction needed from core that ctx did not already offer. Additions only, so minor. ctx.activity.log, because an admin action a module performs has to land in core's one audit log or the trail has a hole exactly where a module operates the game -- a module keeping its own log would be a second place to look, which in practice means a place nobody looks. Write-only; reading the log is the admin panel's job and it spans every actor. ctx.users.getById, one function for one caller: the admin.users.detail slot router needs the user its prefix names. ctx.site.baseUrl, because a module has to build absolute links and §2.7 forbids it reading core's APP_BASE_URL -- a getter, not a captured string, so it cannot go stale against the env. ctx.middleware.rateLimit is core's makeLimiter, plus accountChangeLimiter handed over whole. The split is deliberate: a module states its own window and cap because it knows what its endpoints cost, and takes the plumbing from core so there is one express-rate-limit in the process and one place a breach is logged. accountChangeLimiter is shared policy -- core's /auth/me and /player/account sit behind the same counter -- so a module's account-change route has to land IN it rather than beside it. marketLimiter was UO policy living in core's file and leaves with the route it guards. registerPostHook is the fourth registry, and the last thing binding core to the module. Core's post controller called newsGump.syncPost directly: core's CMS naming a UO file. It now publishes what it already knows and a subscriber decides what to do with it. Not folded into registerAnnounceLeg, which fires on the same transition, because a leg is a one-shot DELIVERY with retry and classification while a post hook maintains idempotent STATE, runs on delete as well as save, and refreshes silently on an edit. Also fixes a real loader defect the extraction exposed: schema table names were matched against the RAW file, so a fragment whose header says "every CREATE TABLE carries IF NOT EXISTS" was rejected for a prefix violation on a table called `carries`. module-uo's fragment hit exactly that. Both scans now read split statements, which strip comments -- the same class of bug as a boundary check failing on its own documentation. Co-Authored-By: Claude <noreply@anthropic.com>
154 lines
5.7 KiB
JavaScript
154 lines
5.7 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.',
|
|
})
|
|
|
|
// 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,
|
|
cspReportLimiter,
|
|
}
|