fix: budget failed sign-ins per client and forward the client address to the mail server

This commit is contained in:
Linus Rath
2026-10-19 00:43:00 +02:00
parent dd0da783c6
commit 89ef4d14f2
9 changed files with 429 additions and 45 deletions
+25 -3
View File
@@ -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());
+26 -3
View File
@@ -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,
+28 -1
View File
@@ -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 });
+3 -2
View File
@@ -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',
]);
});
});
+27
View File
@@ -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);
}
+9 -2
View File
@@ -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
View File
@@ -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);
+13 -2
View File
@@ -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