fix(intake): stop a throttled sender being told their link is dead (#222)
Adding e2e specs for the submission page found a defect in the page they were written for, which is what they were for. One limiter counted page loads and submissions against the same twenty-per-quarter-hour allowance, so a sender working through a box of stock ran out after ten items — the exact person the feature exists for, and the exact case the limiter's own comment said must not be refused. The comment said refusing them costs a consignment while the number quietly did it. Worse, the page could not tell a 429 from a 404. `fetchIntakeLink` treated any non-OK response as "no link", so a throttled sender was told "This link is not active" and sent to ask for a replacement — which could not have helped, because the problem was their address and a minute of patience. Two conditions needing opposite reactions were sharing a message. Now two limiters, because the two requests cost different things. Reading a link hits one indexed row and writes nothing, so that allowance is generous at 120: someone re-reading the form or losing their signal should never be told to wait. Submitting writes up to six files, so that is the one worth bounding, at 30 — more than anyone photographing items can manage and far less than a script would want. The page gains a third state. Unknown, revoked and used-up still collapse into one "not active" card, because whether a link exists is not something a stranger needs to learn. Throttled is deliberately kept apart from them, since "wait a moment" and "go and ask for another link" are opposite instructions. Measured rather than assumed, on a freshly started process both times: before, 25 page loads produced 14 rejections; after, 40 produce none. The first attempt at that measurement was wrong and worth recording — the restart had failed with EADDRINUSE, so it read 30 of 30 against the old process's already-exhausted store. The two specs now pass in a full parallel run alongside everything else. They are scoped the way #241 asks: unique run ids, assertions naming only this run's rows, nothing asserted about the table as a whole. Backend: 284 integration, 309 unit. Frontend: build clean, lint unchanged at 2 pre-existing warnings. Ref #222, #241
This commit is contained in:
@@ -144,13 +144,41 @@ export function keyByCaller(req: Request): string {
|
||||
return ipKeyGenerator(req.ip ?? '');
|
||||
}
|
||||
|
||||
// Deliberately looser than the password-reset allowance. Somebody photographing
|
||||
// a box of stock legitimately submits several items in a row, and the cost of
|
||||
// refusing them is a lost consignment — whereas the cost of allowing a few too
|
||||
// many is some disk the volume guard and the per-link cap already bound.
|
||||
export const intakeLimiter = rateLimit({
|
||||
/**
|
||||
* 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,
|
||||
limit: 20,
|
||||
// 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,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { pool, requireRow } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { hashToken } from '../uploadLinks';
|
||||
import { uploadImages, verifyUploadedImages, insertItemImages } from '../imageUpload';
|
||||
import { intakeLimiter } from '../rateLimit';
|
||||
import { intakeViewLimiter, intakeSubmitLimiter } from '../rateLimit';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -72,7 +72,7 @@ const requireUsableLink = asyncRoute(
|
||||
}
|
||||
);
|
||||
|
||||
router.get('/:token', intakeLimiter, asyncRoute(async (req: Request, res: Response) => {
|
||||
router.get('/:token', intakeViewLimiter, asyncRoute(async (req: Request, res: Response) => {
|
||||
const link = await usableLink(req.params.token as string);
|
||||
if (!link) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
@@ -83,7 +83,7 @@ router.get('/:token', intakeLimiter, asyncRoute(async (req: Request, res: Respon
|
||||
|
||||
router.post(
|
||||
'/:token',
|
||||
intakeLimiter,
|
||||
intakeSubmitLimiter,
|
||||
requireUsableLink,
|
||||
uploadImages,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
|
||||
Reference in New Issue
Block a user