mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
fix: budget failed sign-ins per client and forward the client address to the mail server
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<NextResponse> {
|
||||
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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, string> }) => ({
|
||||
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<string, unknown> = {};
|
||||
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<typeof vi.spyOn>;
|
||||
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<string, string> = {
|
||||
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<unknown> };
|
||||
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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> {
|
||||
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);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
+81
-32
@@ -28,46 +28,95 @@ interface Budget {
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const clients = new Map<string, Budget>();
|
||||
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<string, Budget>();
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user