Files
redefined-designs/backend/src/rateLimit.ts
T
synAdminandClaude Opus 5 87f07baaff
Linting / lint (pull_request) Successful in 3m39s
SonarQube Analysis / sonarqube (pull_request) Failing after 28m35s
feat(auth): the Google sign-in round trip (#341)
Two routes, and a customer whose Google identity is already linked can sign in. Creating accounts and linking them are deliberately held back to the next two issues, so this change is about the protocol alone and can be reviewed as such.

The authorization code flow, with PKCE. The browser is sent to Google, comes back carrying a code, and this server exchanges it over its own TLS connection, so nothing that can read the page ever holds a token. PKCE goes in even though this is a confidential client with a secret: it costs one hash and closes code interception outright rather than resting the whole flow on the secret staying secret.

No JWKS fetch, and the reasoning is written into the module rather than left to be rediscovered. The id token arrives in the response to a request this server made, over TLS, directly to Google's token endpoint, which is exactly the case OpenID Connect permits skipping signature verification for. That removes a key fetch, a cache and a rotation path from the part of the codebase least worth having moving parts in. It removes none of the claim checks, and the comment says plainly that the moment an id token reaches this code from anywhere else, the reasoning stops holding.

So the claim checks are load-bearing rather than belt and braces, and each has a test naming what accepting it blindly would allow. A wrong audience is a token minted for another application being replayed here. A wrong nonce is a token from an earlier attempt. A missing subject is an identity row keyed on nothing. Both spellings of the issuer are accepted because Google really does send both, and taking only one produces sign-ins that fail for some customers and not others.

email_verified is compared to the boolean and never merely tested for truthiness. The string "false" is truthy, and the linking policy turns entirely on this flag, so that one line is the difference between a policy and an account-takeover path.

The attempt cookie is the whole security of the callback, which is a plain GET anyone on the internet can invoke. It carries three secrets, minted separately because they are checked by different parties at different moments: state proves the callback belongs to the request this browser started, nonce proves the token was minted for this attempt, and the verifier proves the code is being spent by whoever asked for it. It is cleared on every path through the callback, so one attempt cannot be replayed even once.

SameSite is Lax and not Strict, and that line has the longest comment in the file because it is the most expensive thing here to get wrong. The callback arrives as a cross-site top-level navigation; Strict withholds the cookie, the state check then fails, and every sign-in is refused with an error that looks exactly like tampering.

Where the customer returns to survives the round trip in that cookie, and it is a value an attacker can propose. Unchecked, the start route is an open redirect wearing a sign-in flow as a disguise: a link on our own domain, with our own certificate, that lands somewhere else. Its own module, so it can be tested without a database and so the next path needing the same question has an obvious place to ask it. Its first draft used a regex that inverted its own character class and rejected every path, which passed every other test and would have broken every real sign-in — there is now a test for exactly that.

Declining at Google's consent screen is a cancellation rather than a failure. The customer goes back where they were with nothing said, the same distinction #41 drew for a dismissed passkey prompt.

A disabled account is refused here as well, because enforcing it on some sign-in routes and not others is how a disabled account keeps a way in.

Signing in calls the shared function, not a third implementation that agrees today. There is a test that the resulting session is accepted by an unrelated route, which is what makes that sharing worth something rather than merely tidy.

Verified: backend tsc clean for src and tests, 590 unit tests pass, lint back to the seven warnings that predate this branch. The integration suite covers the routes end to end with only the token exchange stubbed, and needs a database this machine has no Docker for.

Closes #341

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 08:49:42 -05:00

210 lines
9.8 KiB
TypeScript

import rateLimit, { ipKeyGenerator, MemoryStore } 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.
//
// The caller half goes through ipKeyGenerator rather than using req.ip raw.
// A raw IPv6 address is the full 128 bits, but a residential IPv6 customer is
// delegated an entire prefix and can source every request from a different
// address inside it for free — so keyed on the exact address this limiter
// counted each request as a new caller and never bound at all. That is not a
// small miss: this limiter is the only thing stopping anyone making the server
// send unlimited mail to any address they choose. express-rate-limit reported
// it as ERR_ERL_KEY_GEN_IPV6 on every boot; see #84.
//
// The helper's default groups IPv6 by /56 rather than /64. That is the
// deliberate choice: /56 covers a whole delegated site, so an attacker cannot
// escape the bucket by moving within their own allocation. It does mean
// several households behind one delegation share an allowance — acceptable
// here only because the key also contains the email address, so they collide
// just when targeting the same account. IPv4 is returned unchanged.
//
// Exported for the unit test. The limiter's own allowance is not worth
// asserting in a test — its store is process-wide, so exhausting it leaks into
// every later test from the same address — but the key function is pure and
// is where the bug actually was.
export function keyByCallerAndEmail(req: Request): string {
const email = typeof req.body?.email === 'string' ? req.body.email.toLowerCase().trim() : '';
return `${ipKeyGenerator(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' }
});
// Resending a verification email makes the server send mail on request, which
// is the same class of endpoint as password reset and needs the same treatment.
//
// Keyed on the customer id, which is tighter than either limiter above and
// sidesteps the IPv6 problem of #84 entirely: the caller is signed in, so there
// is an identity better than an address to count against, and no amount of
// moving within a delegated prefix changes it. It also means one customer
// cannot spend anyone else's allowance, which keying on IP would allow.
//
// It does NOT make the store's process-wide lifetime a non-issue for tests, as
// was assumed at first. resetDb truncates with RESTART IDENTITY, so every
// integration test's first customer is id 1 and they all share one bucket:
// three tests that each send once exhaust the allowance for the fourth. The
// store below is explicit and exported so a test can clear it, rather than
// tests being written around an allowance they cannot see.
//
// Must be mounted *after* requireCustomer. Before it, req.customerId is
// undefined and every anonymous caller would share a single bucket — the same
// collapse the passwordResetRequestLimiter comment warns about.
export function keyByCustomer(req: Request): string {
return `customer:${req.customerId ?? 'anonymous'}`;
}
// Three an hour is generous for someone who genuinely lost the mail, and
// useless to anybody hammering it. The window is longer than the 15 minutes
// used above because the failure it guards against is slower: a verification
// link lasts 24 hours, so there is no reason to want a fourth inside an hour.
const VERIFICATION_RESEND_WINDOW_MS = 60 * 60 * 1000;
const VERIFICATION_RESEND_MAX = 3;
// Exported only so the integration suite can clear it between tests. See the
// note above: recycled customer ids make the allowance leak across tests.
export const verificationResendStore = new MemoryStore();
export const verificationResendLimiter = rateLimit({
windowMs: VERIFICATION_RESEND_WINDOW_MS,
limit: VERIFICATION_RESEND_MAX,
keyGenerator: keyByCustomer,
store: verificationResendStore,
standardHeaders: 'draft-7',
legacyHeaders: false,
// Says what actually happened rather than only that a limit was hit. The mail
// almost certainly did send, so "check your spam folder" is both the more
// useful instruction and the more honest one.
message: {
error: 'we have already sent several verification emails recently. Check your spam folder, and try again later.'
}
});
/**
* Keyed on the caller alone, because an intake submission carries no email.
*
* The comment on `keyByCallerAndEmail` warns that a bare `ip:` bucket is a
* shared allowance rather than a per-caller one, and that trade is accepted
* here deliberately: the *link* is the per-caller identity, and its
* `submission_count` against `max_submissions` is the per-caller cap. This
* limiter exists for a different job — bounding what one address can throw at
* an unauthenticated endpoint that writes files to disk.
*
* ipKeyGenerator rather than `req.ip` raw, for the reason #84 records: a
* residential IPv6 customer is delegated a whole prefix and can source every
* request from a different address inside it for free, so keying on the exact
* address counts each one as a new caller and never bounds anything.
*/
export function keyByCaller(req: Request): string {
return ipKeyGenerator(req.ip ?? '');
}
/**
* Two limiters rather than one, because the two requests cost different things.
*
* Reading a link is a page load: it hits one indexed row and writes nothing.
* Submitting writes up to six files to the uploads volume. Counting them
* against a single allowance meant reloading the page consumed the budget for
* sending items, and at twenty apiece that allowance ran out after ten items —
* for exactly the person this feature is for, somebody working through a box
* of stock. The comment here used to say refusing them costs a consignment,
* while the number quietly did it.
*
* Both still key on the caller alone, since a submission carries no email. The
* `keyByCallerAndEmail` comment warns that a bare `ip:` bucket is a shared
* allowance rather than a per-caller one, and that trade is accepted here: the
* link is the per-caller identity and its `max_submissions` is the per-caller
* cap, while these bound what one address can throw at an unauthenticated
* endpoint.
*/
export const intakeViewLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
// Generous, because it is a page load. Someone re-reading the form, losing
// their signal, or coming back to it should never be told to wait.
limit: 120,
keyGenerator: keyByCaller,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { error: 'too many requests — please try again shortly' }
});
export const intakeSubmitLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
// Each of these writes files, so this is the one worth bounding. Thirty in a
// quarter of an hour is more than anyone photographing items can manage and
// far less than a script would want.
limit: 30,
keyGenerator: keyByCaller,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { error: 'too many submissions — please try again later' }
});
/**
* Starting a Google sign-in (#341).
*
* The route mints three secrets and issues a redirect, which is cheap but not
* free, and it is reachable without a session by anyone who knows the URL.
*
* Generous, because a customer who bounces off Google's consent screen and
* tries again is doing something entirely reasonable and must never be told to
* wait. The limit exists so a loop cannot spend the server's entropy and fill
* the log, not to police customers.
*
* Keyed on the caller alone: this endpoint carries no email, which is the
* distinction the comment on the client-error limiter above draws.
*/
export const googleSignInLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 60,
keyGenerator: keyByCaller,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { error: 'too many sign-in attempts — please try again shortly' }
});