From a122d195f9e1cbdb0287bd79348a90b6996a1d9f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 06:30:37 +0000 Subject: [PATCH] fix: signed-out visitors can always reach the way back in A logout plus a couple of reloads spent the whole 100-a-minute allowance and then the throttle refused /login itself with the 402 sales page, so the person could not sign back in (2026-10-06). Measured in headless Chromium: one signed-out view of /invite-only and a click to /login is 38 metered requests, because Next prefetches every link (/login, /signup, /forgot-password, each twice) and every page view also pulled /manifest.json through a 307 to /invite-only. - /invite-only, /login, /signup, /forgot-password, /reset-password and /manifest.json are open paths for the throttle. - /api/auth/me gets the house allowance instead of sharing the 10-a-minute sign-in bucket, which page loads spent before the login POST arrived. - /manifest.json is served while invite only rather than redirected. Sign-in attempts stay at 10 a minute and every other route at 100; tests cover both and fail on the old code. Co-Authored-By: Claude Opus 5.5 --- src/lib/site-offline.ts | 4 +++- src/lib/throttle.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ src/lib/throttle.ts | 15 +++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/lib/throttle.test.ts diff --git a/src/lib/site-offline.ts b/src/lib/site-offline.ts index fbbd338f..45607f8c 100644 --- a/src/lib/site-offline.ts +++ b/src/lib/site-offline.ts @@ -19,7 +19,9 @@ export const SITE_OFFLINE = true; -const STILL_SERVED = ['/api/health', '/api/webhooks']; +// The web manifest too: every page links it, and a 307 to /invite-only for it was two +// wasted requests per page view against a signed-out visitor's allowance. +const STILL_SERVED = ['/api/health', '/api/webhooks', '/manifest.json']; export function stillServed(pathname: string): boolean { return STILL_SERVED.some((p) => pathname === p || pathname.startsWith(`${p}/`)); diff --git a/src/lib/throttle.test.ts b/src/lib/throttle.test.ts new file mode 100644 index 00000000..bff5fe8d --- /dev/null +++ b/src/lib/throttle.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { meter } from './throttle'; + +/** A signed-out browser: no session cookie, one address. */ +const from = (ip: string, path: string, init: RequestInit = {}) => + new Request(`https://bittorrented.com${path}`, { + ...init, + headers: { 'x-forwarded-for': ip, 'x-real-ip': ip, 'user-agent': 'Mozilla/5.0 Firefox/140.0' }, + }); + +describe('the site-wide allowance, signed out', () => { + it('never meters the way back in', async () => { + // A logout plus a few reloads: Next prefetches each of these on every page view. + const paths = ['/invite-only', '/login', '/signup', '/forgot-password', '/reset-password', '/manifest.json']; + for (let i = 0; i < 40; i++) { + for (const path of paths) expect(await meter(from('198.51.100.7', path))).toBeNull(); + } + }); + + it('keeps "who am I" out of the sign-in bucket, so page views cannot spend the login', async () => { + const ip = '198.51.100.8'; + for (let i = 0; i < 30; i++) expect(await meter(from(ip, '/api/auth/me'))).toBeNull(); + expect(await meter(from(ip, '/api/auth/login', { method: 'POST' }))).toBeNull(); + }); + + it('still holds sign-in attempts to ten a minute', async () => { + const ip = '198.51.100.9'; + for (let i = 0; i < 10; i++) expect(await meter(from(ip, '/api/auth/login', { method: 'POST' }))).toBeNull(); + const refused = await meter(from(ip, '/api/auth/login', { method: 'POST' })); + expect(refused?.status).toBeGreaterThanOrEqual(402); + }); + + it('still meters everything else at a hundred a minute', async () => { + const ip = '198.51.100.10'; + for (let i = 0; i < 100; i++) expect(await meter(from(ip, '/pricing'))).toBeNull(); + expect((await meter(from(ip, '/pricing')))?.status).toBeGreaterThanOrEqual(402); + }); +}); diff --git a/src/lib/throttle.ts b/src/lib/throttle.ts index 123eed76..4fe548fb 100644 --- a/src/lib/throttle.ts +++ b/src/lib/throttle.ts @@ -48,6 +48,15 @@ export const throttle = createThrottle({ ? (/^Bearer\s+(\S+)/i.exec(request.headers.get('authorization') ?? '')?.[1] ?? null) : null), credential: { limit: 600, ceiling: 1200 }, + /* + * The way back in is never metered. While the site is invite only, a signed-out + * browser can reach almost nothing but these, and Next prefetches every one of + * them (plus a refetch of each 307 to /invite-only) on every page view: one + * logout and two reloads spent the whole hundred, and the person who tripped it + * was then refused the login page itself (2026-10-06). A paywall you cannot + * reach to sign in past is a wall. + */ + openPaths: ['/invite-only', '/login', '/signup', '/forgot-password', '/reset-password', '/manifest.json'], rules: [ /* The expensive ones, at the numbers they were already tuned to. */ { path: '/api/search/', limit: 30 }, @@ -57,6 +66,12 @@ export const throttle = createThrottle({ { path: '/dht', limit: 60 }, /* Sign-in stays address-bucketed, or a guess buys the session budget. */ { path: '/api/auth/', limit: 10, credential: false }, + /* + * Except "who am I", which every page asks on load. In the sign-in bucket it + * spent the ten guesses before anyone typed a password, so the login POST + * that followed a few page views was the request refused. + */ + { path: '/api/auth/me', limit: 100 }, ], });