From 89ef4d14f2d57c20bbab5e61d76af310fa83989c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:43:00 +0200 Subject: [PATCH] fix: budget failed sign-ins per client and forward the client address to the mail server --- app/api/auth/session/route.ts | 28 ++- app/api/auth/stalwart-context/route.ts | 29 ++- app/api/auth/totp-token-exchange/route.ts | 29 ++- app/api/auth/verify/route.ts | 5 +- .../auth-login-routes-budget.test.ts | 217 ++++++++++++++++++ lib/auth/client-address.ts | 27 +++ lib/auth/stalwart-password-login.ts | 11 +- lib/auth/verify-budget.ts | 113 ++++++--- lib/auth/verify-jmap-auth.ts | 15 +- 9 files changed, 429 insertions(+), 45 deletions(-) create mode 100644 lib/__tests__/auth-login-routes-budget.test.ts create mode 100644 lib/auth/client-address.ts diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 276b09afe..1c4317d4e 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -21,6 +21,8 @@ import { revokeImpersonationCredential } from '@/lib/impersonation/app-password' import { IMPERSONATION_GRANT_COOKIE, openImpersonationGrant } from '@/lib/impersonation/grant-cookie'; 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'; function sessionCookieOptions() { return { @@ -132,9 +134,29 @@ export async function POST(request: NextRequest) { // server, such as settings sync. Skipping the check for admin-configured // servers let anyone mint a cookie for any username with a made-up // password. `trusted` only relaxes the public-address requirement. - const normalizedServerUrl = await verifyJmapIdentity(upstreamUrl, authHeader, username, { - trusted: upstreamTrusted, - }); + // 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); + if (!refund) { + return NextResponse.json( + { error: 'too_many_attempts', hint: 'Too many failed sign-in attempts. Try again later.' }, + { status: 429, headers: { 'Retry-After': String(LOGIN_BUDGET_RETRY_AFTER_SECONDS) } }, + ); + } + let normalizedServerUrl: string; + try { + normalizedServerUrl = await verifyJmapIdentity(upstreamUrl, authHeader, username, { + trusted: upstreamTrusted, + clientIp, + }); + } 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(); + throw error; + } + refund(); const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password); const cookieStore = await cookies(); cookieStore.set(cookieName, token, sessionCookieOptions()); diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index 01a40a673..dfab68351 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -9,6 +9,8 @@ import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-server import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; 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'; function getSlot(request: NextRequest, bodySlot: unknown): number { if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS) { @@ -71,9 +73,30 @@ export async function POST(request: NextRequest) { // any user's synced settings. Admin-configured servers are `trusted`, // which only relaxes the public-address requirement (they may live on a // private network) - never the credential check. - const { serverUrl: normalizedServerUrl, accountName } = await resolveJmapIdentity(upstreamUrl, authHeader, username, { - trusted: upstreamTrusted, - }); + // 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); + if (!refund) { + return NextResponse.json( + { error: 'too_many_attempts', hint: 'Too many failed sign-in attempts. Try again later.' }, + { status: 429, headers: { 'Retry-After': String(LOGIN_BUDGET_RETRY_AFTER_SECONDS) } }, + ); + } + let normalizedServerUrl: string; + let accountName: string; + try { + ({ serverUrl: normalizedServerUrl, accountName } = await resolveJmapIdentity(upstreamUrl, authHeader, username, { + trusted: upstreamTrusted, + clientIp, + })); + } 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(); + throw error; + } + refund(); await setStalwartAuthContext(slot, { serverUrl: normalizedServerUrl, diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 0b6416c0c..eb1cb72c5 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -12,6 +12,8 @@ import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { DEFAULT_CLIENT_ID } from '@/lib/oauth/token-exchange'; 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'; /** * Exchange a password + (optional) TOTP code for OAuth tokens. @@ -35,6 +37,7 @@ async function attemptLogin( slot: number, serverId: string | null, upstreamTrusted: boolean, + clientIp: string, ): Promise { const base = upstreamUrl.replace(/\/+$/, ''); @@ -52,6 +55,15 @@ async function attemptLogin( || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || ''; + // 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); + if (!refund) { + return NextResponse.json( + { error: 'too_many_attempts' }, + { status: 429, headers: { 'Retry-After': String(LOGIN_BUDGET_RETRY_AFTER_SECONDS) } }, + ); + } const result = await stalwartPasswordLogin({ serverUrl: base, username, @@ -61,7 +73,12 @@ async function attemptLogin( clientSecret, redirectUri, trusted: upstreamTrusted, + clientIp, }); + // 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) { switch (result.error) { @@ -160,7 +177,17 @@ export async function POST(request: NextRequest) { ? bodyRedirectUri : trimUrl(upstreamUrl); - return await attemptLogin(upstreamUrl, username, password, totpCode, redirectUri, slot, resolvedServerId, upstreamTrusted); + return await attemptLogin( + upstreamUrl, + username, + password, + totpCode, + redirectUri, + slot, + resolvedServerId, + upstreamTrusted, + loginClientAddress(request), + ); } catch (error) { 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 780ad690b..59efca39b 100644 --- a/app/api/auth/verify/route.ts +++ b/app/api/auth/verify/route.ts @@ -82,14 +82,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 refund = reserveVerifyProbe(getClientIP(request)); + const clientIp = getClientIP(request); + const refund = reserveVerifyProbe(clientIp); if (!refund) return respond('inconclusive'); const authHeader = hasToken ? `Bearer ${token}` : 'Basic ' + Buffer.from(username + ':' + password).toString('base64'); try { - await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted }); + await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted, clientIp }); refund(); return respond('ok'); } catch (error) { diff --git a/lib/__tests__/auth-login-routes-budget.test.ts b/lib/__tests__/auth-login-routes-budget.test.ts new file mode 100644 index 000000000..793cc52f5 --- /dev/null +++ b/lib/__tests__/auth-login-routes-budget.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * The sign-in routes forward the credential to the mail server from this + * host's address, exactly like the /api/auth/verify pre-check, but had no + * failure budget: a script posting wrong passwords could trip the mail + * server's per-address limiter against the webmail and lock everyone out, + * and use the routes as an unthrottled password oracle. Each client now gets + * the pre-check's budget, and the client's own address is forwarded upstream + * so a mail server that trusts this host blames the right party. + */ + +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() }, +})); + +const cookieSet = vi.fn(); +vi.mock('next/headers', () => ({ + cookies: async () => ({ get: () => undefined, set: cookieSet, 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(), +})); + +vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: vi.fn() })); + +const SERVER = 'https://mail.example.org'; +const HOST = 'webmail.example'; +const PASSWORD = 'alice-secret'; + +function json(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } }); +} + +let fetchSpy: ReturnType; +let upstreamCalls: { url: string; init?: RequestInit }[]; + +/** A mail server with one user; Basic auth for the session routes, the structured login for TOTP. */ +function installFakeServer() { + upstreamCalls = []; + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = String(input); + upstreamCalls.push({ url, init }); + if (url === `${SERVER}/api/auth`) { + const body = JSON.parse(String(init?.body)) as { accountSecret: string; mfaToken?: string }; + if (body.accountSecret !== PASSWORD) return json({ type: 'failure' }); + if (!body.mfaToken) return json({ type: 'mfaRequired' }); + return json({ type: 'authenticated', client_code: 'code-1' }); + } + if (url === `${SERVER}/auth/token`) { + return json({ access_token: 'at', expires_in: 3600, refresh_token: 'rt' }); + } + const auth = new Headers(init?.headers).get('authorization') || ''; + const [, pass = ''] = Buffer.from(auth.replace(/^Basic\s+/i, ''), 'base64').toString('utf8').split(':'); + if (pass !== PASSWORD) return json({ status: 401 }, 401); + if (url === `${SERVER}/.well-known/jmap`) { + return json({ apiUrl: `${SERVER}/jmap/`, username: 'alice@example.org', primaryAccounts: {}, accounts: {} }); + } + return json({ error: `unexpected ${url}` }, 404); + }); +} + +function mockRequest(body: unknown, clientIp?: string) { + const headers: Record = { + host: HOST, + origin: `https://${HOST}`, + 'content-type': 'application/json', + 'sec-fetch-site': 'same-origin', + }; + if (clientIp) headers['x-forwarded-for'] = clientIp; + 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<{ error?: string; ok?: boolean }> }; + +async function post(route: string, body: unknown, clientIp?: string) { + const mod = (await import(/* @vite-ignore */ route)) as { POST: (r: unknown) => Promise }; + const res = (await mod.POST(mockRequest(body, clientIp))) as RouteResponse; + return { status: res.status, retryAfter: res.headers.get('retry-after'), body: await res.json() }; +} + +const SESSION = '@/app/api/auth/session/route'; +const CONTEXT = '@/app/api/auth/stalwart-context/route'; +const TOTP = '@/app/api/auth/totp-token-exchange/route'; + +const basic = (pass: string) => `Basic ${Buffer.from(`alice@example.org:${pass}`).toString('base64')}`; +const sessionBody = (password: string) => ({ serverUrl: SERVER, username: 'alice@example.org', password }); +const contextBody = (password: string) => ({ serverUrl: SERVER, username: 'alice@example.org', authHeader: basic(password) }); +const totpBody = (password: string, totp?: string) => ({ serverUrl: SERVER, username: 'alice@example.org', password, totp }); + +beforeEach(() => { + for (const k of Object.keys(config)) delete config[k]; + config.jmapServerUrl = SERVER; + cookieSet.mockClear(); + vi.resetModules(); + installFakeServer(); +}); + +afterEach(() => { + fetchSpy.mockRestore(); +}); + +describe.each([ + ['/api/auth/session', SESSION, sessionBody], + ['/api/auth/stalwart-context', CONTEXT, contextBody], +])('%s failure budget', (_name, route, body) => { + it('stops forwarding passwords upstream after five wrong ones from one client', async () => { + for (let i = 0; i < 5; i++) { + expect((await post(route, body('wrong'), '198.51.100.7')).status).toBe(401); + } + const calls = upstreamCalls.length; + const over = await post(route, body('wrong'), '198.51.100.7'); + expect(over.status).toBe(429); + expect(over.body.error).toBe('too_many_attempts'); + expect(Number(over.retryAfter)).toBeGreaterThan(0); + expect(upstreamCalls.length).toBe(calls); + + // The right password from that client is refused too until the window passes... + expect((await post(route, body(PASSWORD), '198.51.100.7')).status).toBe(429); + expect(cookieSet).not.toHaveBeenCalled(); + // ...while another client still signs in. + expect((await post(route, body(PASSWORD), '198.51.100.8')).status).toBe(200); + }); + + it('does not count right passwords or outages against the budget', async () => { + for (let i = 0; i < 8; i++) { + expect((await post(route, body(PASSWORD), '198.51.100.9')).status).toBe(200); + } + fetchSpy.mockImplementation(async () => { + throw new TypeError('fetch failed'); + }); + for (let i = 0; i < 8; i++) { + expect((await post(route, body('wrong'), '198.51.100.9')).status).not.toBe(429); + } + }); + + it('forwards the client address upstream, and only a real one', async () => { + await post(route, body(PASSWORD), '198.51.100.10'); + expect(new Headers(upstreamCalls[0].init?.headers).get('x-forwarded-for')).toBe('198.51.100.10'); + + upstreamCalls = []; + await post(route, body(PASSWORD), 'evil\u0000, not an address'); + expect(new Headers(upstreamCalls[0].init?.headers).get('x-forwarded-for')).toBeNull(); + + upstreamCalls = []; + await post(route, body(PASSWORD)); + expect(new Headers(upstreamCalls[0].init?.headers).get('x-forwarded-for')).toBeNull(); + }); +}); + +describe('/api/auth/totp-token-exchange failure budget', () => { + it('stops forwarding passwords upstream after five wrong ones from one client', async () => { + for (let i = 0; i < 5; i++) { + expect((await post(TOTP, totpBody('wrong'), '198.51.100.7')).body.error).toBe('invalid_credentials'); + } + const calls = upstreamCalls.length; + const over = await post(TOTP, totpBody('wrong'), '198.51.100.7'); + expect(over.status).toBe(429); + expect(over.body.error).toBe('too_many_attempts'); + expect(upstreamCalls.length).toBe(calls); + expect((await post(TOTP, totpBody(PASSWORD, '123456'), '198.51.100.8')).status).toBe(200); + }); + + it('does not count the TOTP prompt, a successful login or an outage', async () => { + for (let i = 0; i < 8; i++) { + expect((await post(TOTP, totpBody(PASSWORD), '198.51.100.9')).body.error).toBe('totp_required'); + expect((await post(TOTP, totpBody(PASSWORD, '123456'), '198.51.100.9')).status).toBe(200); + } + fetchSpy.mockImplementation(async () => { + throw new TypeError('fetch failed'); + }); + for (let i = 0; i < 8; i++) { + expect((await post(TOTP, totpBody('wrong'), '198.51.100.9')).body.error).toBe('login_unreachable'); + } + }); + + it('forwards the client address on both login steps', async () => { + await post(TOTP, totpBody(PASSWORD, '123456'), '198.51.100.10'); + expect(upstreamCalls.map((c) => new Headers(c.init?.headers).get('x-forwarded-for'))).toEqual([ + '198.51.100.10', + '198.51.100.10', + ]); + }); +}); diff --git a/lib/auth/client-address.ts b/lib/auth/client-address.ts new file mode 100644 index 000000000..a27856950 --- /dev/null +++ b/lib/auth/client-address.ts @@ -0,0 +1,27 @@ +import { isIP } from 'node:net'; +import { getClientIP } from '@/lib/admin/session'; + +/** + * Every credential the sign-in routes check reaches the mail server from this + * host's address. Stalwart counts failed logins per address (fail2ban) and + * bans the address once the allowance is spent, so wrong passwords typed by + * or fired at the webmail would eventually lock the webmail itself out for + * everyone. Stalwart reads `X-Forwarded-For` from connections that come from + * a configured trusted network (`server.proxy.trusted-networks`), so handing + * it the real client address lets it attribute the failure to that client + * instead. A server that does not trust this host ignores the header. + * + * Only a syntactically valid address is forwarded: the client-facing value + * comes from the request's own `X-Forwarded-For` when no reverse proxy + * rewrites it, and anything else would be a stranger's text in an upstream + * header. + */ +export function forwardedClientHeaders(clientIp: string | undefined): Record { + if (!clientIp || clientIp === '0.0.0.0' || isIP(clientIp) === 0) return {}; + return { 'X-Forwarded-For': clientIp }; +} + +/** The client address of `request` as the sign-in routes forward and budget it. */ +export function loginClientAddress(request: Request): string { + return getClientIP(request); +} diff --git a/lib/auth/stalwart-password-login.ts b/lib/auth/stalwart-password-login.ts index a79279b7e..7bfe227e5 100644 --- a/lib/auth/stalwart-password-login.ts +++ b/lib/auth/stalwart-password-login.ts @@ -1,6 +1,7 @@ import { logger } from '@/lib/logger'; import { DisallowedUrlError, fetchPublicUrl } from '@/lib/security/url-guard'; import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce'; +import { forwardedClientHeaders } from '@/lib/auth/client-address'; /** * Exchange a password + (optional) TOTP code for OAuth tokens at Stalwart. @@ -41,6 +42,12 @@ export interface StalwartPasswordLoginOptions { * admin-configured servers may live on private addresses. */ trusted: boolean; + /** + * Address of the signing-in client, forwarded as `X-Forwarded-For` so a + * mail server that trusts this host counts a wrong password against that + * client rather than against the webmail (see lib/auth/client-address.ts). + */ + clientIp?: string; } export interface StalwartTokens { @@ -107,7 +114,7 @@ export async function stalwartPasswordLogin( try { const loginResponse = await upstreamFetch(`${base}/api/auth`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...forwardedClientHeaders(opts.clientIp) }, body: JSON.stringify({ type: 'authCode', accountName: opts.username, @@ -174,7 +181,7 @@ export async function stalwartPasswordLogin( try { const tokenResponse = await upstreamFetch(tokenEndpoint, { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...forwardedClientHeaders(opts.clientIp) }, body: tokenParams.toString(), }); diff --git a/lib/auth/verify-budget.ts b/lib/auth/verify-budget.ts index 7452ccfd2..bca207673 100644 --- a/lib/auth/verify-budget.ts +++ b/lib/auth/verify-budget.ts @@ -28,46 +28,95 @@ interface Budget { resetAt: number; } -const clients = new Map(); -let global: Budget = { used: 0, resetAt: 0 }; - -function fresh(budget: Budget | undefined, now: number): Budget { - return budget && budget.resetAt > now ? budget : { used: 0, resetAt: now + WINDOW_MS }; +export interface FailureBudgetOptions { + /** Failures one client may cause per window. */ + clientMax: number; + /** Failures all clients together may cause per window; `Infinity` for no ceiling. */ + globalMax: number; + windowMs?: number; } -function prune(now: number): void { - for (const [key, budget] of clients) { - if (budget.resetAt <= now) clients.delete(key); - } - // Still full of live windows: drop the oldest. - while (clients.size >= MAX_TRACKED_CLIENTS) { - const oldest = clients.keys().next().value; - if (oldest === undefined) break; - clients.delete(oldest); - } +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(client: string, now?: number): (() => void) | null; } +export function createFailureBudget(options: FailureBudgetOptions): FailureBudget { + const windowMs = options.windowMs ?? WINDOW_MS; + const clients = new Map(); + let global: Budget = { used: 0, resetAt: 0 }; + + function fresh(budget: Budget | undefined, now: number): Budget { + return budget && budget.resetAt > now ? budget : { used: 0, resetAt: now + windowMs }; + } + + function prune(now: number): void { + for (const [key, budget] of clients) { + if (budget.resetAt <= now) clients.delete(key); + } + // Still full of live windows: drop the oldest. + while (clients.size >= MAX_TRACKED_CLIENTS) { + const oldest = clients.keys().next().value; + if (oldest === undefined) break; + clients.delete(oldest); + } + } + + 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); + }; + }, + }; +} + +const verifyBudget = createFailureBudget({ 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. */ export function reserveVerifyProbe(client: string, now: number = Date.now()): (() => void) | null { - global = fresh(global, now); - const own = fresh(clients.get(client), now); - if (global.used >= GLOBAL_MAX_FAILURES || own.used >= CLIENT_MAX_FAILURES) 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); - }; + return verifyBudget.reserve(client, now); } + +/** + * The sign-in routes themselves (/api/auth/session, /api/auth/stalwart-context, + * /api/auth/totp-token-exchange) forward the credential to the mail server + * exactly like the pre-check does, so they are the same password oracle and + * burn the same upstream failure allowance. They have no browser-side + * fallback, so a client over budget gets a 429 instead of an answer. There is + * no global ceiling here: one would let a handful of wrong passwords lock + * every user out of signing in for the window, which is the outcome this + * guards against. The real client address is forwarded upstream instead (see + * 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 }); + +export function reserveLoginAttempt(client: string, now: number = Date.now()): (() => void) | null { + return loginBudget.reserve(client, now); +} + +/** Seconds a client over budget is told to wait (Retry-After). */ +export const LOGIN_BUDGET_RETRY_AFTER_SECONDS = Math.ceil(WINDOW_MS / 1000); diff --git a/lib/auth/verify-jmap-auth.ts b/lib/auth/verify-jmap-auth.ts index 238310c6e..5ddc0cc42 100644 --- a/lib/auth/verify-jmap-auth.ts +++ b/lib/auth/verify-jmap-auth.ts @@ -4,6 +4,7 @@ import { isPublicHttpUrl, type PublicFetchResponse, } from '@/lib/security/url-guard'; +import { forwardedClientHeaders } from '@/lib/auth/client-address'; const VERIFY_TIMEOUT_MS = 10000; const MAX_REDIRECTS = 3; @@ -157,6 +158,12 @@ export interface VerifiedJmapSession { export interface VerifyJmapAuthOptions { trusted?: boolean; + /** + * Address of the client whose credential is checked, forwarded upstream as + * `X-Forwarded-For` so a mail server that trusts this host counts a wrong + * password against that client rather than against the webmail. + */ + clientIp?: string; } /** @@ -307,7 +314,11 @@ async function fetchIdentityEmails( } const requestInit = { method: 'POST', - headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, + headers: { + Authorization: authHeader, + 'Content-Type': 'application/json', + ...forwardedClientHeaders(options.clientIp), + }, body: JSON.stringify({ using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [['Identity/get', { accountId, ids: null }, '0']], @@ -384,7 +395,7 @@ async function fetchVerifiedSession( const requestInit = { method: 'GET', - headers: { Authorization: authHeader }, + headers: { Authorization: authHeader, ...forwardedClientHeaders(options.clientIp) }, signal: controller.signal, }; // Untrusted (user-supplied) endpoints connect through the rebinding-safe