From 388daf767b619d604a283416e9a2b200f4e83d0c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:04:05 +0100 Subject: [PATCH] feat: count sign-in, pre-check, pair step-up and hand-off limits across every instance #1066 --- app/api/admin/policy/route.ts | 3 + app/api/auth/handoff/confirm/route.ts | 2 + app/api/auth/handoff/create/route.ts | 4 +- app/api/auth/handoff/redeem/route.ts | 6 +- app/api/auth/pair/create/route.ts | 14 +- app/api/auth/session/route.ts | 9 +- app/api/auth/stalwart-context/route.ts | 9 +- app/api/auth/totp-token-exchange/route.ts | 7 +- app/api/auth/verify/route.ts | 17 ++- lib/__tests__/auth-routes-store-down.test.ts | 144 +++++++++++++++++++ lib/__tests__/helpers/shared-store-down.ts | 30 ++++ lib/__tests__/pair-attempts.test.ts | 66 ++++----- lib/__tests__/shared-auth-budgets.test.ts | 127 ++++++++++++++++ lib/auth/bearer-identity.ts | 14 +- lib/auth/handoff-store.ts | 12 +- lib/auth/pair-attempts.ts | 73 ++++++++-- lib/auth/store-unavailable.ts | 26 ++++ lib/auth/verify-budget.ts | 109 ++++++++++---- 18 files changed, 574 insertions(+), 98 deletions(-) create mode 100644 lib/__tests__/auth-routes-store-down.test.ts create mode 100644 lib/__tests__/helpers/shared-store-down.ts create mode 100644 lib/__tests__/shared-auth-budgets.test.ts create mode 100644 lib/auth/store-unavailable.ts diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index 205c66f24..cd06925e2 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -6,6 +6,7 @@ import { logger } from '@/lib/logger'; import { POLICY_DOMAINS_HEADER, POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types'; import { canSeeInstanceDetails, signedInIdentity } from '@/lib/auth/instance-details'; import { bearerIdentity } from '@/lib/auth/bearer-identity'; +import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; import { scopesVisibleTo } from '@/lib/admin/policy-scopes'; import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; @@ -56,6 +57,8 @@ export async function GET(request: NextRequest) { headers: { 'Cache-Control': 'no-store' }, }); } catch (error) { + // A Bearer token can't be checked without the shared store (its budget). + if (isStoreUnavailable(error)) return storeUnavailableResponse(); logger.error('Policy read error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/app/api/auth/handoff/confirm/route.ts b/app/api/auth/handoff/confirm/route.ts index e909fb23b..8e550f7f4 100644 --- a/app/api/auth/handoff/confirm/route.ts +++ b/app/api/auth/handoff/confirm/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { confirmHandoff, isHandoffCode } from '@/lib/auth/handoff-store'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; // The SSO callback page calls this when the user confirms the hand-off to the // mobile app, right before it opens the app's link. /api/auth/sso/complete @@ -23,6 +24,7 @@ export async function POST(request: NextRequest) { } return NextResponse.json({ ok: true }, { headers: NO_STORE }); } catch (error) { + if (isStoreUnavailable(error)) return storeUnavailableResponse(NO_STORE); logger.error('Mobile hand-off confirm error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE }); } diff --git a/app/api/auth/handoff/create/route.ts b/app/api/auth/handoff/create/route.ts index 0e03ccd05..e63ee0082 100644 --- a/app/api/auth/handoff/create/route.ts +++ b/app/api/auth/handoff/create/route.ts @@ -3,6 +3,7 @@ import { logger } from '@/lib/logger'; import { isHandoffCodeChallenge, reserveHandoffCreate, stashHandoff } from '@/lib/auth/handoff-store'; import { loginClientAddress } from '@/lib/auth/client-address'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; // Password sign-in hand-off to the mobile app, browser side. The login page // was opened by the app with a `mobile_code_challenge`; after the user signed @@ -42,7 +43,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'invalid_request' }, { status: 400, headers: NO_STORE }); } - if (!reserveHandoffCreate(loginClientAddress(request))) { + if (!(await reserveHandoffCreate(loginClientAddress(request)))) { return NextResponse.json({ error: 'too_many_requests' }, { status: 429, headers: NO_STORE }); } @@ -53,6 +54,7 @@ export async function POST(request: NextRequest) { logger.info('Mobile hand-off parked', { flow: 'password' }); return NextResponse.json({ code }, { headers: NO_STORE }); } catch (error) { + if (isStoreUnavailable(error)) return storeUnavailableResponse(NO_STORE); logger.error('Mobile hand-off create error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE }); } diff --git a/app/api/auth/handoff/redeem/route.ts b/app/api/auth/handoff/redeem/route.ts index 96c57eaf8..bf0adc04a 100644 --- a/app/api/auth/handoff/redeem/route.ts +++ b/app/api/auth/handoff/redeem/route.ts @@ -8,6 +8,7 @@ import { } from '@/lib/auth/handoff-store'; import { loginClientAddress } from '@/lib/auth/client-address'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; // The mobile app's side of the browser sign-in hand-off. It POSTs the code // from its callback link with the PKCE verifier behind the challenge it sent @@ -26,7 +27,7 @@ export async function POST(request: NextRequest) { const crossOrigin = rejectCrossOriginRequest(request); if (crossOrigin) return crossOrigin; try { - const refund = reserveHandoffRedeem(loginClientAddress(request)); + const refund = await reserveHandoffRedeem(loginClientAddress(request)); if (!refund) { return NextResponse.json({ error: 'too_many_requests' }, { status: 429, headers: NO_STORE }); } @@ -50,10 +51,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error }, { status: 400, headers: NO_STORE }); } - refund(); + await refund(); logger.info('Mobile hand-off redeemed', { flow: result.bundle.flow }); return NextResponse.json({ ...result.bundle, state: result.state }, { headers: NO_STORE }); } catch (error) { + if (isStoreUnavailable(error)) return storeUnavailableResponse(NO_STORE); logger.error('Mobile hand-off redeem error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE }); } diff --git a/app/api/auth/pair/create/route.ts b/app/api/auth/pair/create/route.ts index f7d85eff4..952e7d802 100644 --- a/app/api/auth/pair/create/route.ts +++ b/app/api/auth/pair/create/route.ts @@ -14,6 +14,7 @@ import { findServerByUrl, parseJmapServers } from '@/lib/admin/jmap-servers'; import { IMPERSONATION_GRANT_COOKIE, openImpersonationGrant } from '@/lib/impersonation/grant-cookie'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; // Desktop side of "Link mobile app". Returns a one-time pairing code for the // QR; the phone redeems it at /api/auth/pair/redeem. @@ -80,6 +81,11 @@ const STEP_UP_ERRORS = { pairing_unavailable: 502, } as const; +// The answer stands; a count that couldn't be settled stays a failure (the safe side). +function settleFailed(error: unknown): void { + logger.warn('Pair step-up attempt not settled', { error: error instanceof Error ? error.message : 'Unknown error' }); +} + export async function POST(request: NextRequest) { // CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax, // so a cross-site top-level POST would otherwise reach this handler. @@ -128,7 +134,7 @@ export async function POST(request: NextRequest) { // was written under an identity address. const accountName = context.accountName ?? context.username; const attemptKey = pairAttemptKey(serverUrl, accountName); - if (!beginPairStepUp(attemptKey)) { + if (!(await beginPairStepUp(attemptKey))) { return NextResponse.json({ error: 'too_many_attempts' }, { status: 429 }); } const totp = typeof body?.totp === 'string' && body.totp.trim() ? body.totp.trim() : undefined; @@ -146,10 +152,10 @@ export async function POST(request: NextRequest) { }); if (!result.ok) { const wrong = result.error === 'invalid_credentials' || (result.error === 'totp_required' && !!totp); - settlePairStepUp(attemptKey, wrong ? 'wrong_credential' : 'other'); + await settlePairStepUp(attemptKey, wrong ? 'wrong_credential' : 'other').catch(settleFailed); return NextResponse.json({ error: result.error }, { status: STEP_UP_ERRORS[result.error] }); } - settlePairStepUp(attemptKey, 'success'); + await settlePairStepUp(attemptKey, 'success').catch(settleFailed); grantId = stashGrant(result.grant, owner); setPairReauthInStore(cookieStore, { grantId, owner }); logger.info('Pair step-up succeeded', { flow: result.grant.flow, method: 'password' }); @@ -171,6 +177,8 @@ export async function POST(request: NextRequest) { { headers: { 'Cache-Control': 'no-store' } }, ); } catch (error) { + // Attempts can't be counted, nor grants kept, without the shared store. + if (isStoreUnavailable(error)) return storeUnavailableResponse({ 'Cache-Control': 'no-store' }); logger.error('Pair create error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 8f2384ecd..494ab31e7 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -23,6 +23,7 @@ import { revokeWopiTokens } from '@/lib/wopi/revocation'; import { clearPairReauthInStore } from '@/lib/auth/pair-reauth'; import { loginClientAddress } from '@/lib/auth/client-address'; import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget'; +import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age'; function sessionCookieOptions() { @@ -184,7 +185,7 @@ export async function POST(request: NextRequest) { // Wrong credentials tried from here count against this server's address // upstream, so each client gets a failure budget (lib/auth/verify-budget.ts). const clientIp = loginClientAddress(request); - const refund = reserveLoginAttempt(clientIp); + const refund = await reserveLoginAttempt(clientIp); if (!refund) { return NextResponse.json( { error: 'too_many_attempts', hint: 'Too many failed sign-in attempts. Try again later.' }, @@ -205,10 +206,10 @@ export async function POST(request: NextRequest) { } catch (error) { // Only a credential the server rejected spends the budget; an outage // or a mismatched claim says nothing about the password. - if (!(error instanceof JmapAuthVerificationError && error.status === 401)) refund(); + if (!(error instanceof JmapAuthVerificationError && error.status === 401)) await refund(); throw error; } - refund(); + await refund(); // A remembered sign-in starts the maximum session length. const signedInAt = Date.now(); const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password, signedInAt); @@ -228,6 +229,8 @@ export async function POST(request: NextRequest) { // The browser counts the maximum session length from the server's time. return NextResponse.json(warning ? { ok: true, warning, signedInAt } : { ok: true, signedInAt }); } catch (error) { + // The sign-in limit can't be counted without the shared store: refused. + if (isStoreUnavailable(error)) return storeUnavailableResponse(); if (error instanceof JmapAuthVerificationError) { return NextResponse.json( verificationFailureBody('session', error, upstreamUrl, upstreamTrusted), diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index fe25532d3..b82ce7c1b 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -14,6 +14,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { insecureCookieHint, verificationFailureBody } from '@/lib/auth/verification-failure'; import { loginClientAddress } from '@/lib/auth/client-address'; import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget'; +import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; import { recordStoredCredentialStatus } from '@/lib/stalwart/stored-credential-budget'; function getSlot(request: NextRequest, bodySlot: unknown): number { @@ -80,7 +81,7 @@ export async function POST(request: NextRequest) { // Wrong credentials tried from here count against this server's address // upstream, so each client gets a failure budget (lib/auth/verify-budget.ts). const clientIp = loginClientAddress(request); - const refund = reserveLoginAttempt(clientIp); + const refund = await reserveLoginAttempt(clientIp); if (!refund) { return NextResponse.json( { error: 'too_many_attempts', hint: 'Too many failed sign-in attempts. Try again later.' }, @@ -97,10 +98,10 @@ export async function POST(request: NextRequest) { } catch (error) { // Only a credential the server rejected spends the budget; an outage // or a mismatched claim says nothing about the password. - if (!(error instanceof JmapAuthVerificationError && error.status === 401)) refund(); + if (!(error instanceof JmapAuthVerificationError && error.status === 401)) await refund(); throw error; } - refund(); + await refund(); // Just signed in: an older refusal of this credential no longer applies. recordStoredCredentialStatus(authHeader, 200); @@ -127,6 +128,8 @@ export async function POST(request: NextRequest) { if (warning) logger.warn(`stalwart-context: ${warning}`); return NextResponse.json(warning ? { ok: true, warning } : { ok: true }); } catch (error) { + // The sign-in limit can't be counted without the shared store: refused. + if (isStoreUnavailable(error)) return storeUnavailableResponse(); if (error instanceof JmapAuthVerificationError) { return NextResponse.json( verificationFailureBody('stalwart-context', error, upstreamUrl, upstreamTrusted), diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 71ecf1209..4c374a4f3 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -14,6 +14,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login'; import { loginClientAddress } from '@/lib/auth/client-address'; import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget'; +import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; import { writeSignInStamp } from '@/lib/auth/session-age'; /** @@ -59,7 +60,7 @@ async function attemptLogin( // Wrong passwords tried from here count against this server's address // upstream, so each client gets a failure budget (lib/auth/verify-budget.ts). - const refund = reserveLoginAttempt(clientIp); + const refund = await reserveLoginAttempt(clientIp); if (!refund) { return NextResponse.json( { error: 'too_many_attempts' }, @@ -82,7 +83,7 @@ async function attemptLogin( // Only a rejected password spends the budget. `totp_required` means the // password was right and the code is still missing, which every TOTP login // goes through once; outages say nothing about the password. - if (result.ok || result.error !== 'invalid_credentials') refund(); + if (result.ok || result.error !== 'invalid_credentials') await refund(); if (!result.ok) { switch (result.error) { @@ -197,6 +198,8 @@ export async function POST(request: NextRequest) { rememberMe !== false, ); } catch (error) { + // The sign-in limit can't be counted without the shared store: refused. + if (isStoreUnavailable(error)) return storeUnavailableResponse(); logger.error('TOTP login error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts index 59efca39b..028d6432a 100644 --- a/app/api/auth/verify/route.ts +++ b/app/api/auth/verify/route.ts @@ -6,7 +6,8 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { getClientIP } from '@/lib/admin/session'; -import { reserveVerifyProbe } from '@/lib/auth/verify-budget'; +import { reserveVerifyProbe, type BudgetRefund } from '@/lib/auth/verify-budget'; +import { isStoreUnavailable } from '@/lib/auth/store-unavailable'; /** * Server-side credential pre-check for the login form (#969): a password @@ -83,7 +84,15 @@ export async function POST(request: NextRequest) { // Wrong passwords tried from here count against this server's address // upstream; over budget the browser probes the JMAP server itself. const clientIp = getClientIP(request); - const refund = reserveVerifyProbe(clientIp); + let refund: BudgetRefund | null; + try { + refund = await reserveVerifyProbe(clientIp); + } catch (error) { + // The shared store is down: the budget can't be counted, so nothing + // is sent upstream and the browser probes for itself. + if (isStoreUnavailable(error)) return respond('inconclusive'); + throw error; + } if (!refund) return respond('inconclusive'); const authHeader = hasToken @@ -91,13 +100,13 @@ export async function POST(request: NextRequest) { : 'Basic ' + Buffer.from(username + ':' + password).toString('base64'); try { await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted, clientIp }); - refund(); + await refund(); return respond('ok'); } catch (error) { if (error instanceof JmapAuthVerificationError && error.upstreamStatus === 401) { return respond('unauthorized'); } - refund(); + await refund(); logger.debug('Login pre-check inconclusive', { error: error instanceof Error ? error.message : 'Unknown error', }); diff --git a/lib/__tests__/auth-routes-store-down.test.ts b/lib/__tests__/auth-routes-store-down.test.ts new file mode 100644 index 000000000..d7a881d96 --- /dev/null +++ b/lib/__tests__/auth-routes-store-down.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// With a shared store (#1066) the sign-in limits live in the store. When it +// can't be reached the limits can't be counted, and a limit that can't be +// counted must refuse, never let the attempt through to the mail server. + +vi.mock('next/server', () => ({ + NextResponse: { + json: (data: unknown, init?: { status?: number; headers?: Record }) => ({ + json: async () => data, + status: init?.status ?? 200, + headers: new Headers(init?.headers), + }), + }, +})); + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})); + +vi.mock('next/headers', () => ({ + cookies: async () => ({ get: () => undefined, set: vi.fn(), delete: vi.fn(), getAll: () => [] }), +})); + +const config: Record = {}; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback), + }, +})); + +vi.mock('@/lib/auth/crypto', () => ({ + encryptSession: () => 'encrypted-session', + decryptSession: () => null, + decryptPayload: () => null, + encryptPayload: () => 'encrypted', +})); + +vi.mock('@/lib/stalwart/auth-context', () => ({ + setStalwartAuthContext: vi.fn(), + setStalwartAuthContextInStore: vi.fn(), + clearStalwartAuthContextInStore: vi.fn(), + readStalwartAuthContextFromStore: () => null, +})); + +vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: vi.fn() })); + +import { setStoreForTests } from '@/lib/shared-store'; +import { resetLocalStoreForTests } from '@/lib/shared-store/local'; +import { switchableSharedStore } from './helpers/shared-store-down'; + +const SERVER = 'https://mail.example.org'; +const HOST = 'webmail.example'; +const CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; + +function request(body: unknown) { + const headers: Record = { + host: HOST, + origin: `https://${HOST}`, + 'content-type': 'application/json', + 'sec-fetch-site': 'same-origin', + 'x-forwarded-for': '198.51.100.7', + }; + return { + method: 'POST', + json: async () => body, + headers: { get: (k: string) => headers[k.toLowerCase()] ?? null }, + nextUrl: { searchParams: new URLSearchParams(), origin: `https://${HOST}` }, + }; +} + +type RouteResponse = { status: number; headers: Headers; json: () => Promise> }; + +async function post(route: string, body: unknown) { + const mod = (await import(/* @vite-ignore */ route)) as { POST: (r: unknown) => Promise }; + const res = (await mod.POST(request(body))) as RouteResponse; + return { status: res.status, retryAfter: res.headers.get('retry-after'), body: await res.json() }; +} + +let store: ReturnType; +let fetchSpy: ReturnType; + +beforeEach(() => { + for (const k of Object.keys(config)) delete config[k]; + config.jmapServerUrl = SERVER; + vi.resetModules(); + resetLocalStoreForTests(); + store = switchableSharedStore(); + store.down = true; + setStoreForTests(store); + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response('{}', { status: 401 })); +}); + +afterEach(() => { + fetchSpy.mockRestore(); + setStoreForTests(null); + resetLocalStoreForTests(); +}); + +const basic = `Basic ${Buffer.from('alice@example.org:secret').toString('base64')}`; + +describe('sign-in routes while the shared store is down', () => { + it.each([ + ['/api/auth/session', '@/app/api/auth/session/route', { serverUrl: SERVER, username: 'alice@example.org', password: 'secret' }], + ['/api/auth/stalwart-context', '@/app/api/auth/stalwart-context/route', { serverUrl: SERVER, username: 'alice@example.org', authHeader: basic }], + ['/api/auth/totp-token-exchange', '@/app/api/auth/totp-token-exchange/route', { serverUrl: SERVER, username: 'alice@example.org', password: 'secret' }], + ])('%s refuses with 503 and sends nothing upstream', async (_name, route, body) => { + const res = await post(route, body); + expect(res.status).toBe(503); + expect(res.retryAfter).toBe('10'); + expect(res.body.error).toBe('temporarily_unavailable'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('the login pre-check answers inconclusive and sends nothing upstream', async () => { + const res = await post('@/app/api/auth/verify/route', { serverUrl: SERVER, username: 'alice', password: 'secret' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ result: 'inconclusive' }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('the mobile hand-off neither parks nor redeems a sign-in', async () => { + const created = await post('@/app/api/auth/handoff/create/route', { + code_challenge: CHALLENGE, + state: 's', + server_url: SERVER, + username: 'alice@example.org', + password: 'secret', + }); + expect(created.status).toBe(503); + const redeemed = await post('@/app/api/auth/handoff/redeem/route', { code: 'a'.repeat(64), code_verifier: 'v'.repeat(43) }); + expect(redeemed.status).toBe(503); + }); + + it('signs in again once the store is back', async () => { + store.down = false; + const res = await post('@/app/api/auth/session/route', { serverUrl: SERVER, username: 'alice@example.org', password: 'secret' }); + // The fake mail server refuses every password: the attempt got through to it. + expect(res.status).toBe(401); + expect(fetchSpy).toHaveBeenCalled(); + }); +}); diff --git a/lib/__tests__/helpers/shared-store-down.ts b/lib/__tests__/helpers/shared-store-down.ts new file mode 100644 index 000000000..6c83de58b --- /dev/null +++ b/lib/__tests__/helpers/shared-store-down.ts @@ -0,0 +1,30 @@ +import { fakeSharedStore } from '@/lib/shared-store/__tests__/fake-shared'; +import { StoreUnavailableError, type SharedStore } from '@/lib/shared-store/types'; + +/** + * A shared store that can be taken down: while `down` is set every kv, + * counter and collection call rejects with StoreUnavailableError, as the + * Redis store does when its server can't be reached. + */ +export function switchableSharedStore(): SharedStore & { down: boolean } { + const base = fakeSharedStore(); + const guard = (target: T): T => + new Proxy(target, { + get(obj, prop, receiver) { + const value = Reflect.get(obj, prop, receiver); + if (typeof value !== 'function') return value; + return (...args: unknown[]) => { + if (store.down) return Promise.reject(new StoreUnavailableError('down (test)')); + return value.apply(obj, args); + }; + }, + }); + const store: SharedStore & { down: boolean } = { + ...base, + down: false, + kv: guard(base.kv), + counters: guard(base.counters), + collection: (name, options) => guard(base.collection(name, options)), + }; + return store; +} diff --git a/lib/__tests__/pair-attempts.test.ts b/lib/__tests__/pair-attempts.test.ts index b719e08b3..a26e70cdf 100644 --- a/lib/__tests__/pair-attempts.test.ts +++ b/lib/__tests__/pair-attempts.test.ts @@ -23,60 +23,60 @@ beforeEach(() => { }); describe('pair step-up attempts', () => { - it('locks after five failures', () => { - for (let i = 0; i < 4; i++) recordPairStepUpFailure(OWNER, T0 + i); - expect(isPairStepUpLocked(OWNER, T0 + 10)).toBe(false); - recordPairStepUpFailure(OWNER, T0 + 5); - expect(isPairStepUpLocked(OWNER, T0 + 10)).toBe(true); + it('locks after five failures', async () => { + for (let i = 0; i < 4; i++) await recordPairStepUpFailure(OWNER, T0 + i); + expect(await isPairStepUpLocked(OWNER, T0 + 10)).toBe(false); + await recordPairStepUpFailure(OWNER, T0 + 5); + expect(await isPairStepUpLocked(OWNER, T0 + 10)).toBe(true); }); - it('unlocks when the fifteen-minute window has passed', () => { - for (let i = 0; i < 5; i++) recordPairStepUpFailure(OWNER, T0); - expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS - 1)).toBe(true); - expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS)).toBe(false); + it('unlocks when the fifteen-minute window has passed', async () => { + for (let i = 0; i < 5; i++) await recordPairStepUpFailure(OWNER, T0); + expect(await isPairStepUpLocked(OWNER, T0 + WINDOW_MS - 1)).toBe(true); + expect(await isPairStepUpLocked(OWNER, T0 + WINDOW_MS)).toBe(false); // A failure after the window starts a new count. - recordPairStepUpFailure(OWNER, T0 + WINDOW_MS); - expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS + 1)).toBe(false); + await recordPairStepUpFailure(OWNER, T0 + WINDOW_MS); + expect(await isPairStepUpLocked(OWNER, T0 + WINDOW_MS + 1)).toBe(false); }); - it('counts the window from the first failure, not the last', () => { - recordPairStepUpFailure(OWNER, T0); - for (let i = 0; i < 4; i++) recordPairStepUpFailure(OWNER, T0 + WINDOW_MS - 10 + i); - expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS - 1)).toBe(true); - expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS)).toBe(false); + it('counts the window from the first failure, not the last', async () => { + await recordPairStepUpFailure(OWNER, T0); + for (let i = 0; i < 4; i++) await recordPairStepUpFailure(OWNER, T0 + WINDOW_MS - 10 + i); + expect(await isPairStepUpLocked(OWNER, T0 + WINDOW_MS - 1)).toBe(true); + expect(await isPairStepUpLocked(OWNER, T0 + WINDOW_MS)).toBe(false); }); - it('keeps accounts apart', () => { - for (let i = 0; i < 5; i++) recordPairStepUpFailure(OWNER, T0); - expect(isPairStepUpLocked('1:alice@example.org', T0)).toBe(false); - expect(isPairStepUpLocked('0:bob@example.org', T0)).toBe(false); + it('keeps accounts apart', async () => { + for (let i = 0; i < 5; i++) await recordPairStepUpFailure(OWNER, T0); + expect(await isPairStepUpLocked('1:alice@example.org', T0)).toBe(false); + expect(await isPairStepUpLocked('0:bob@example.org', T0)).toBe(false); }); - it('clears the count after a successful step-up', () => { - for (let i = 0; i < 5; i++) recordPairStepUpFailure(OWNER, T0); - clearPairStepUpFailures(OWNER); - expect(isPairStepUpLocked(OWNER, T0)).toBe(false); + it('clears the count after a successful step-up', async () => { + for (let i = 0; i < 5; i++) await recordPairStepUpFailure(OWNER, T0); + await clearPairStepUpFailures(OWNER); + expect(await isPairStepUpLocked(OWNER, T0)).toBe(false); }); - it('drops entries whose day ran out, so the map stays bounded', () => { - recordPairStepUpFailure('0:a@example.org', T0); - recordPairStepUpFailure('0:b@example.org', T0); - recordPairStepUpFailure('0:c@example.org', T0 + DAY_MS); + it('drops entries whose day ran out, so the map stays bounded', async () => { + await recordPairStepUpFailure('0:a@example.org', T0); + await recordPairStepUpFailure('0:b@example.org', T0); + await recordPairStepUpFailure('0:c@example.org', T0 + DAY_MS); const map = (globalThis as unknown as { __bulwarkPairAttempts: Map }).__bulwarkPairAttempts; expect([...map.keys()]).toEqual(['0:c@example.org']); }); - it("caps failures per day well below the mail server ban", () => { + it("caps failures per day well below the mail server ban", async () => { // 5 per window, window after window: the 21st attempt of the day is refused. let now = T0; for (let i = 0; i < 20; i++) { if (i > 0 && i % 5 === 0) now += WINDOW_MS; - expect(beginPairStepUp(OWNER, now)).toBe(true); - settlePairStepUp(OWNER, 'wrong_credential'); + expect(await beginPairStepUp(OWNER, now)).toBe(true); + await settlePairStepUp(OWNER, 'wrong_credential'); } now += WINDOW_MS; - expect(isPairStepUpLocked(OWNER, now)).toBe(true); - expect(isPairStepUpLocked(OWNER, T0 + DAY_MS)).toBe(false); + expect(await isPairStepUpLocked(OWNER, now)).toBe(true); + expect(await isPairStepUpLocked(OWNER, T0 + DAY_MS)).toBe(false); }); it('keys an account by server and login name, whatever the cookie slot', () => { diff --git a/lib/__tests__/shared-auth-budgets.test.ts b/lib/__tests__/shared-auth-budgets.test.ts new file mode 100644 index 000000000..0283f6026 --- /dev/null +++ b/lib/__tests__/shared-auth-budgets.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})); + +import { setStoreForTests } from '@/lib/shared-store'; +import { resetLocalStoreForTests } from '@/lib/shared-store/local'; +import { switchableSharedStore } from './helpers/shared-store-down'; + +// With several replicas behind a load balancer every sign-in limit lived in +// one process's memory: each replica granted its own five wrong passwords, +// its own 30 pre-checks and its own pair step-ups (#1066). With a shared +// store they are counted once for the whole deployment. + +const T0 = 1_800_000_000_000; +const CLIENT = '198.51.100.7'; +let store: ReturnType; + +/** + * Another replica: a fresh copy of the module, and none of the process + * state the auth stores pin on globalThis (only the store itself is shared). + */ +async function replica(load: () => Promise): Promise { + vi.resetModules(); + const g = globalThis as Record; + delete g.__bulwarkPairAttempts; + delete g.__bulwarkMobileHandoffStore; + return load(); +} +const budgets = () => replica(() => import('@/lib/auth/verify-budget')); +const attempts = () => replica(() => import('@/lib/auth/pair-attempts')); +const handoff = () => replica(() => import('@/lib/auth/handoff-store')); + +beforeEach(() => { + resetLocalStoreForTests(); + store = switchableSharedStore(); + setStoreForTests(store); +}); + +afterEach(() => { + setStoreForTests(null); + resetLocalStoreForTests(); +}); + +describe('sign-in budgets with a shared store', () => { + it('counts login failures of one client across replicas', async () => { + const a = await budgets(); + for (let i = 0; i < 3; i++) expect(await a.reserveLoginAttempt(CLIENT, T0)).not.toBeNull(); + const b = await budgets(); + for (let i = 0; i < 2; i++) expect(await b.reserveLoginAttempt(CLIENT, T0)).not.toBeNull(); + const c = await budgets(); + expect(await c.reserveLoginAttempt(CLIENT, T0)).toBeNull(); + // Another client has its own budget, and the window still ends. + expect(await c.reserveLoginAttempt('198.51.100.8', T0)).not.toBeNull(); + expect(await c.reserveLoginAttempt(CLIENT, T0 + 15 * 60 * 1000)).not.toBeNull(); + }); + + it('does not count a refused attempt, and a refund on one replica frees a try on another', async () => { + const a = await budgets(); + const refunds = []; + for (let i = 0; i < 5; i++) refunds.push(await a.reserveLoginAttempt(CLIENT, T0)); + const b = await budgets(); + expect(await b.reserveLoginAttempt(CLIENT, T0)).toBeNull(); + expect(await b.reserveLoginAttempt(CLIENT, T0)).toBeNull(); + await refunds[0]!(); + await refunds[0]!(); // refunds once only + const c = await budgets(); + expect(await c.reserveLoginAttempt(CLIENT, T0)).not.toBeNull(); + expect(await c.reserveLoginAttempt(CLIENT, T0)).toBeNull(); + }); + + it('holds the pre-check ceiling of 30 across clients and replicas', async () => { + for (let i = 0; i < 30; i++) { + const replicaBudget = i % 3 === 0 ? await budgets() : (await import('@/lib/auth/verify-budget')); + expect(await replicaBudget.reserveVerifyProbe(`203.0.113.${i}`, T0)).not.toBeNull(); + } + const other = await budgets(); + expect(await other.reserveVerifyProbe('203.0.113.200', T0)).toBeNull(); + }); + + it('refuses rather than allows when the store is down', async () => { + const a = await budgets(); + store.down = true; + await expect(a.reserveLoginAttempt(CLIENT, T0)).rejects.toThrow('down'); + await expect(a.reserveVerifyProbe(CLIENT, T0)).rejects.toThrow('down'); + }); + + it('counts pair step-up failures of one account across replicas', async () => { + const key = 'https://mail.example.org|alice@example.org'; + for (let i = 0; i < 5; i++) { + const replicaAttempts = await attempts(); + expect(await replicaAttempts.beginPairStepUp(key, T0 + i)).toBe(true); + await replicaAttempts.settlePairStepUp(key, 'wrong_credential', T0 + i); + } + const next = await attempts(); + expect(await next.isPairStepUpLocked(key, T0 + 10)).toBe(true); + expect(await next.beginPairStepUp(key, T0 + 10)).toBe(false); + + // An attempt that said nothing about the password is given back. + await next.clearPairStepUpFailures(key); + expect(await next.beginPairStepUp(key, T0 + 20)).toBe(true); + await (await attempts()).settlePairStepUp(key, 'other', T0 + 20); + for (let i = 0; i < 5; i++) expect(await (await attempts()).beginPairStepUp(key, T0 + 30)).toBe(true); + expect(await (await attempts()).beginPairStepUp(key, T0 + 30)).toBe(false); + }); + + it('caps pair step-ups at 20 a day across replicas', async () => { + const key = 'https://mail.example.org|bob@example.org'; + let now = T0; + for (let i = 0; i < 20; i++) { + if (i > 0 && i % 5 === 0) now += 15 * 60 * 1000; + expect(await (await attempts()).beginPairStepUp(key, now)).toBe(true); + } + now += 15 * 60 * 1000; + expect(await (await attempts()).isPairStepUpLocked(key, now)).toBe(true); + expect(await (await attempts()).isPairStepUpLocked(key, T0 + 24 * 60 * 60 * 1000)).toBe(false); + }); + + it('counts hand-off creates and redeems across replicas', async () => { + for (let i = 0; i < 10; i++) expect(await (await handoff()).reserveHandoffCreate(CLIENT, T0)).toBe(true); + expect(await (await handoff()).reserveHandoffCreate(CLIENT, T0)).toBe(false); + for (let i = 0; i < 10; i++) expect(await (await handoff()).reserveHandoffRedeem(CLIENT, T0)).not.toBeNull(); + expect(await (await handoff()).reserveHandoffRedeem(CLIENT, T0)).toBeNull(); + }); +}); diff --git a/lib/auth/bearer-identity.ts b/lib/auth/bearer-identity.ts index c38be766b..aa3c2081f 100644 --- a/lib/auth/bearer-identity.ts +++ b/lib/auth/bearer-identity.ts @@ -42,7 +42,7 @@ interface CacheEntry { } const cache = new Map(); -const refusedBudget = createFailureBudget({ clientMax: 10, globalMax: Infinity }); +const refusedBudget = createFailureBudget({ name: 'bearer-refused', clientMax: 10, globalMax: Infinity }); function bearerToken(request: Request): string | null { const header = request.headers.get('authorization'); @@ -81,21 +81,22 @@ function prune(now: number): void { } async function verify(serverUrl: string, token: string, client: string): Promise { - const refund = refusedBudget.reserve(client); + // Store down: StoreUnavailableError reaches the caller, nothing is checked. + const refund = await refusedBudget.reserve(client); if (!refund) return null; try { const identity = await fetchJmapSessionIdentity(serverUrl, `Bearer ${token}`, { trusted: true, clientIp: client, }); - refund(); + await refund(); return { addresses: identity.addresses }; } catch (error) { const refused = error instanceof JmapAuthVerificationError && error.upstreamStatus !== undefined && (error.upstreamStatus === 401 || error.upstreamStatus === 403); // Only a token the server refused counts against the client. if (!refused) { - refund(); + await refund(); logger.warn('Bearer identity check failed', { error: error instanceof Error ? error.message : 'Unknown error', }); @@ -107,6 +108,8 @@ async function verify(serverUrl: string, token: string, client: string): Promise /** * The identity behind the request's Bearer token, or null when there is no * token, no configured server to check it against, or the server refused it. + * Rejects with StoreUnavailableError when the refused-token budget can't be + * counted (shared store down). */ export async function bearerIdentity(request: Request, now = Date.now()): Promise { const token = bearerToken(request); @@ -125,6 +128,9 @@ export async function bearerIdentity(request: Request, now = Date.now()): Promis // A refused token is checked again sooner than a good one. void result.then((identity) => { if (!identity) entry.expiresAt = Math.min(entry.expiresAt, now + INVALID_TTL_MS); + }, () => { + // Not checked at all (the shared store is down): ask again next time. + if (cache.get(key) === entry) cache.delete(key); }); return result; } diff --git a/lib/auth/handoff-store.ts b/lib/auth/handoff-store.ts index d4a2f6a53..a4aba3fef 100644 --- a/lib/auth/handoff-store.ts +++ b/lib/auth/handoff-store.ts @@ -1,7 +1,7 @@ import { createHash, randomBytes, timingSafeEqual } from 'crypto'; import type { RedeemBundle } from '@/lib/auth/pair-bundle'; import { isMobileCodeChallenge } from '@/lib/auth/mobile-handoff'; -import { createFailureBudget } from '@/lib/auth/verify-budget'; +import { createFailureBudget, type BudgetRefund } from '@/lib/auth/verify-budget'; // One-time codes for the browser sign-in hand-off to the mobile app // (lib/auth/mobile-handoff.ts, bulwarkmail.org/docs/development/mobile-handoff). The app opens the @@ -167,18 +167,18 @@ export function resetHandoffStoreForTests(): void { // Per-client limits on the routes. Codes carry 256 bits and die on the first // attempt, so guessing is hopeless either way; these keep one client from // filling the store or hammering the redeem route. -const createBudget = createFailureBudget({ clientMax: 10, globalMax: Infinity }); -const redeemBudget = createFailureBudget({ clientMax: 10, globalMax: Infinity }); +const createBudget = createFailureBudget({ name: 'handoff-create', clientMax: 10, globalMax: Infinity }); +const redeemBudget = createFailureBudget({ name: 'handoff-redeem', clientMax: 10, globalMax: Infinity }); /** Count one parked sign-in for `client`; false when it is over its limit. */ -export function reserveHandoffCreate(client: string, now = Date.now()): boolean { - return createBudget.reserve(client, now) !== null; +export async function reserveHandoffCreate(client: string, now = Date.now()): Promise { + return (await createBudget.reserve(client, now)) !== null; } /** * Reserve one redeem attempt for `client`: null when it is over its limit, * otherwise a refund to call when the redeem succeeded (only failures count). */ -export function reserveHandoffRedeem(client: string, now = Date.now()): (() => void) | null { +export function reserveHandoffRedeem(client: string, now = Date.now()): Promise { return redeemBudget.reserve(client, now); } diff --git a/lib/auth/pair-attempts.ts b/lib/auth/pair-attempts.ts index 16870e771..62aa1c678 100644 --- a/lib/auth/pair-attempts.ts +++ b/lib/auth/pair-attempts.ts @@ -12,6 +12,14 @@ // starts, and is given back only once it turns out not to have been a wrong // credential: otherwise parallel requests would all pass the check before the // first one failed. +// +// One process keeps the counts in memory, as always. With a shared store +// (#1066) they are counters there, so every replica counts the same attempts; +// when the store is down every function rejects with StoreUnavailableError +// and the step-up is refused. + +import { createHash } from 'node:crypto'; +import { getStore, isSharedMode } from '@/lib/shared-store'; const MAX_FAILURES = 5; const WINDOW_MS = 15 * 60 * 1000; @@ -51,12 +59,42 @@ function live(key: string, now: number): Entry | null { return entry; } -export function isPairStepUpLocked(key: string, now = Date.now()): boolean { +// ─── shared store ────────────────────────────────────────────────────────── + +/** The two counters of an account; the key itself holds a login name, so it is hashed. */ +function sharedLimits(key: string) { + const id = createHash('sha256').update(key).digest('base64url'); + return [ + { key: `pair-attempts:window:${id}`, max: MAX_FAILURES, windowMs: WINDOW_MS }, + { key: `pair-attempts:day:${id}`, max: MAX_DAILY_FAILURES, windowMs: DAY_MS }, + ]; +} + +// ─── API ─────────────────────────────────────────────────────────────────── + +export async function isPairStepUpLocked(key: string, now = Date.now()): Promise { + if (isSharedMode()) { + const limits = sharedLimits(key); + const counts = await Promise.all(limits.map((limit) => getStore().counters.get(limit.key, now))); + return limits.some((limit, i) => (counts[i]?.count ?? 0) >= limit.max); + } + return isLockedLocal(key, now); +} + +export async function recordPairStepUpFailure(key: string, now = Date.now()): Promise { + if (isSharedMode()) { + await getStore().counters.reserve(sharedLimits(key).map((limit) => ({ ...limit, max: Infinity })), now); + return; + } + recordFailureLocal(key, now); +} + +function isLockedLocal(key: string, now: number): boolean { const entry = live(key, now); return !!entry && (entry.failures >= MAX_FAILURES || entry.daily >= MAX_DAILY_FAILURES); } -export function recordPairStepUpFailure(key: string, now = Date.now()): void { +function recordFailureLocal(key: string, now: number): void { const entry = live(key, now); if (entry) { entry.failures += 1; @@ -74,9 +112,16 @@ export function recordPairStepUpFailure(key: string, now = Date.now()): void { * Start a step-up: false when the account is locked out, otherwise the * attempt is counted as a failure until {@link settlePairStepUp} says better. */ -export function beginPairStepUp(key: string, now = Date.now()): boolean { - if (isPairStepUpLocked(key, now)) return false; - recordPairStepUpFailure(key, now); +export async function beginPairStepUp(key: string, now = Date.now()): Promise { + if (isSharedMode()) { + // Check and count in one step, so parallel attempts on several replicas + // can't all pass before the first one is counted. + return (await getStore().counters.reserve(sharedLimits(key), now)).ok; + } + // Check and count with nothing in between, as parallel requests would + // otherwise all pass the check. + if (isLockedLocal(key, now)) return false; + recordFailureLocal(key, now); return true; } @@ -85,12 +130,20 @@ export function beginPairStepUp(key: string, now = Date.now()): boolean { * count, a wrong credential keeps its failure, anything else (the server was * unreachable, pairing is unavailable) gives the attempt back. */ -export function settlePairStepUp(key: string, outcome: 'success' | 'wrong_credential' | 'other'): void { +export async function settlePairStepUp( + key: string, + outcome: 'success' | 'wrong_credential' | 'other', + now = Date.now(), +): Promise { if (outcome === 'success') { - clearPairStepUpFailures(key); + await clearPairStepUpFailures(key); return; } if (outcome === 'other') { + if (isSharedMode()) { + await getStore().counters.refund(sharedLimits(key).map((limit) => ({ key: limit.key })), now); + return; + } const entry = store().get(key); if (entry) { entry.failures = Math.max(0, entry.failures - 1); @@ -99,6 +152,10 @@ export function settlePairStepUp(key: string, outcome: 'success' | 'wrong_creden } } -export function clearPairStepUpFailures(key: string): void { +export async function clearPairStepUpFailures(key: string): Promise { + if (isSharedMode()) { + await Promise.all(sharedLimits(key).map((limit) => getStore().counters.reset(limit.key))); + return; + } store().delete(key); } diff --git a/lib/auth/store-unavailable.ts b/lib/auth/store-unavailable.ts new file mode 100644 index 000000000..8d0fdb878 --- /dev/null +++ b/lib/auth/store-unavailable.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; +import { StoreUnavailableError } from '@/lib/shared-store/types'; + +/** + * Answers for when the shared store (#1066) can't be reached. Sign-in limits + * fail closed: a limit that can't be counted refuses the attempt instead of + * letting it through. Pairing and the mobile hand-off can neither issue nor + * redeem anything without the store. + */ + +export const STORE_RETRY_AFTER_SECONDS = 10; + +/** + * Also by name: Next gives each bundle its own copy of the error class, and + * the one store on globalThis throws the copy of whichever bundle created it. + */ +export function isStoreUnavailable(error: unknown): error is StoreUnavailableError { + return error instanceof StoreUnavailableError || (error instanceof Error && error.name === 'StoreUnavailableError'); +} + +export function storeUnavailableResponse(headers: Record = {}): NextResponse { + return NextResponse.json( + { error: 'temporarily_unavailable', hint: 'Sign-in is temporarily unavailable. Try again in a few seconds.' }, + { status: 503, headers: { ...headers, 'Retry-After': String(STORE_RETRY_AFTER_SECONDS) } }, + ); +} diff --git a/lib/auth/verify-budget.ts b/lib/auth/verify-budget.ts index bca207673..f33bc6960 100644 --- a/lib/auth/verify-budget.ts +++ b/lib/auth/verify-budget.ts @@ -11,8 +11,16 @@ * Over budget the route answers `inconclusive`: the browser then probes the * JMAP server itself, from its own address, exactly as it did before the * pre-check existed. + * + * With a shared store (#1066) the counts live there, so every replica spends + * the same budget. If the store can't be reached, `reserve` throws + * StoreUnavailableError and the caller refuses the attempt. */ +import { createHash } from 'node:crypto'; +import { getStore, isSharedMode, type CounterLimit } from '@/lib/shared-store'; +import { logger } from '@/lib/logger'; + const WINDOW_MS = 15 * 60 * 1000; const CLIENT_MAX_FAILURES = 5; /** @@ -29,6 +37,8 @@ interface Budget { } export interface FailureBudgetOptions { + /** Names this budget's counters in the shared store. */ + name: string; /** Failures one client may cause per window. */ clientMax: number; /** Failures all clients together may cause per window; `Infinity` for no ceiling. */ @@ -36,13 +46,22 @@ export interface FailureBudgetOptions { windowMs?: number; } +/** Gives a reserved attempt back. Never throws; calling it twice refunds once. */ +export type BudgetRefund = () => Promise; + export interface FailureBudget { /** - * Reserve one attempt for `client`. Returns null when the client or the - * whole server is over budget; otherwise a function that refunds the - * attempt, to be called when the credentials turned out to be right. + * Reserve one attempt for `client`. Resolves to null when the client or + * the whole server is over budget; otherwise to a function that refunds + * the attempt, to be called when the credentials turned out to be right. + * Rejects with StoreUnavailableError when the shared store is down. */ - reserve(client: string, now?: number): (() => void) | null; + reserve(client: string, now?: number): Promise; +} + +/** A client address as a counter key: as it is when it's short and printable, else hashed. */ +function clientKey(client: string): string { + return /^[\x21-\x7e]{1,128}$/.test(client) ? client : `h:${createHash('sha256').update(client).digest('base64url')}`; } export function createFailureBudget(options: FailureBudgetOptions): FailureBudget { @@ -66,37 +85,69 @@ export function createFailureBudget(options: FailureBudgetOptions): FailureBudge } } + // One process: today's in-memory windows. + function reserveLocal(client: string, now: number): BudgetRefund | null { + global = fresh(global, now); + const own = fresh(clients.get(client), now); + if (global.used >= options.globalMax || own.used >= options.clientMax) return null; + + if (!clients.has(client)) prune(now); + own.used++; + global.used++; + clients.set(client, own); + + const reservedGlobal = global; + let refunded = false; + return async () => { + if (refunded) return; + refunded = true; + own.used = Math.max(0, own.used - 1); + reservedGlobal.used = Math.max(0, reservedGlobal.used - 1); + }; + } + + // Several replicas: one atomic reservation over the client's and the + // global counter; a refused attempt counts nowhere. + async function reserveShared(client: string, now: number): Promise { + const limits: CounterLimit[] = [{ key: `budget:${options.name}:client:${clientKey(client)}`, max: options.clientMax, windowMs }]; + if (Number.isFinite(options.globalMax)) { + limits.push({ key: `budget:${options.name}:all`, max: options.globalMax, windowMs }); + } + const store = getStore(); + const result = await store.counters.reserve(limits, now); + if (!result.ok) return null; + let refunded = false; + return async () => { + if (refunded) return; + refunded = true; + try { + // Only from the windows the attempt was counted in. + await store.counters.refund(limits.map((limit, i) => ({ key: limit.key, resetAt: result.resetAts[i] })), now); + } catch (error) { + // The attempt stays counted: the safe side. + logger.warn('Could not refund a sign-in attempt', { + budget: options.name, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }; + } + return { - reserve(client: string, now: number = Date.now()): (() => void) | null { - global = fresh(global, now); - const own = fresh(clients.get(client), now); - if (global.used >= options.globalMax || own.used >= options.clientMax) return null; - - if (!clients.has(client)) prune(now); - own.used++; - global.used++; - clients.set(client, own); - - const reservedGlobal = global; - let refunded = false; - return () => { - if (refunded) return; - refunded = true; - own.used = Math.max(0, own.used - 1); - reservedGlobal.used = Math.max(0, reservedGlobal.used - 1); - }; + async reserve(client: string, now: number = Date.now()): Promise { + return isSharedMode() ? reserveShared(client, now) : reserveLocal(client, now); }, }; } -const verifyBudget = createFailureBudget({ clientMax: CLIENT_MAX_FAILURES, globalMax: GLOBAL_MAX_FAILURES }); +const verifyBudget = createFailureBudget({ name: 'verify', clientMax: CLIENT_MAX_FAILURES, globalMax: GLOBAL_MAX_FAILURES }); /** - * Reserve one probe for `client`. Returns null when the client or the whole - * server is over budget; otherwise a function that refunds the probe, to be - * called when the credentials turned out to be right. + * Reserve one probe for `client`. Resolves to null when the client or the + * whole server is over budget; otherwise to a function that refunds the + * probe, to be called when the credentials turned out to be right. */ -export function reserveVerifyProbe(client: string, now: number = Date.now()): (() => void) | null { +export function reserveVerifyProbe(client: string, now: number = Date.now()): Promise { return verifyBudget.reserve(client, now); } @@ -112,9 +163,9 @@ export function reserveVerifyProbe(client: string, now: number = Date.now()): (( * lib/auth/client-address.ts), so a mail server that trusts this host * attributes the failures to the client that caused them. */ -const loginBudget = createFailureBudget({ clientMax: CLIENT_MAX_FAILURES, globalMax: Infinity }); +const loginBudget = createFailureBudget({ name: 'login', clientMax: CLIENT_MAX_FAILURES, globalMax: Infinity }); -export function reserveLoginAttempt(client: string, now: number = Date.now()): (() => void) | null { +export function reserveLoginAttempt(client: string, now: number = Date.now()): Promise { return loginBudget.reserve(client, now); }