The frontend's error boundaries need somewhere to report to. A boundary that only shows a customer a message leaves nobody knowing it happened, which is the failure shape this project has designed against three times already. POST /api/client-errors takes a report, truncates its fields, logs it with a [client-error] prefix and returns 204. No storage: the container log is where this project's operational visibility already lives, and a table with a retention policy and an admin screen is a subsystem larger than the issue. An unrecognised context is a 400 rather than a log line under a guessed label, following parseItemFilters, which refuses a malformed filter instead of coercing it. Oversized fields go the other way and are truncated rather than refused, because an over-long report is still the only record of the failure. The endpoint gets its own rate limiter rather than reusing passwordResetRequestLimiter, whose comment already warns that its caller-and-email key collapses every caller into one shared bucket on an endpoint without an email. The new one takes the default key generator, which also avoids the ERR_ERL_KEY_GEN_IPV6 warning the custom key produces. Verified: 138 integration tests pass, 4 of them new, and 79 unit. The unit count rose by one without a test being written — routesAreWrapped.test.ts runs describe.each over the files in src/routes, so a new route file generates a case. The handler is synchronous and needs no asyncRoute wrapper. Refs #62 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
57 lines
2.4 KiB
TypeScript
57 lines
2.4 KiB
TypeScript
import rateLimit from 'express-rate-limit';
|
|
import { Request } from 'express';
|
|
|
|
// First rate limiting in the codebase. The password-reset endpoints need it
|
|
// most: without one, anyone can make the server send unlimited mail to any
|
|
// address. Login and registration are the obvious next candidates.
|
|
//
|
|
// The default in-memory store suits a single-instance deployment, which this
|
|
// is. Running more than one app container would need a shared store, or each
|
|
// instance would enforce its own separate allowance.
|
|
|
|
const WINDOW_MS = 15 * 60 * 1000;
|
|
const MAX_REQUESTS = 5;
|
|
|
|
// Keyed on caller *and* address rather than caller alone. Keying on IP only
|
|
// would let one person's reset attempts lock out everyone behind the same
|
|
// NAT or reverse proxy — and everything here arrives via Nginx Proxy Manager,
|
|
// so a great many customers share an apparent address.
|
|
//
|
|
// This key only makes sense on a request that carries an email. Applying the
|
|
// same limiter to an endpoint without one collapses every caller into a single
|
|
// `ip:` bucket, which is a shared allowance rather than a per-caller one.
|
|
function keyByCallerAndEmail(req: Request): string {
|
|
const email = typeof req.body?.email === 'string' ? req.body.email.toLowerCase().trim() : '';
|
|
return `${req.ip}:${email}`;
|
|
}
|
|
|
|
export const passwordResetRequestLimiter = rateLimit({
|
|
windowMs: WINDOW_MS,
|
|
limit: MAX_REQUESTS,
|
|
keyGenerator: keyByCallerAndEmail,
|
|
standardHeaders: 'draft-7',
|
|
legacyHeaders: false,
|
|
message: { error: 'too many attempts, please try again later' }
|
|
});
|
|
|
|
// Client error reports carry no email, so this one is keyed on the caller
|
|
// alone — deliberately not reusing passwordResetRequestLimiter, whose comment
|
|
// above explains why its key is wrong for an endpoint without an email.
|
|
//
|
|
// `trust proxy` is set in app.ts, so `req.ip` is the real client address from
|
|
// X-Forwarded-For rather than Nginx Proxy Manager's, making this a per-customer
|
|
// allowance rather than one shared by everybody behind the proxy.
|
|
//
|
|
// Generous, because hitting the limit is harmless: the reporter ignores the
|
|
// response either way. It exists so a render loop cannot fill the log.
|
|
const CLIENT_ERROR_WINDOW_MS = 15 * 60 * 1000;
|
|
const CLIENT_ERROR_MAX_REQUESTS = 30;
|
|
|
|
export const clientErrorLimiter = rateLimit({
|
|
windowMs: CLIENT_ERROR_WINDOW_MS,
|
|
limit: CLIENT_ERROR_MAX_REQUESTS,
|
|
standardHeaders: 'draft-7',
|
|
legacyHeaders: false,
|
|
message: { error: 'too many reports' }
|
|
});
|