diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 1afa1ab96..32b044ac2 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -184,7 +184,7 @@ export async function POST(request: NextRequest) { // password. `trusted` only relaxes the public-address requirement. // 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 clientIp = loginClientAddress(request, upstreamUrl); const refund = await reserveLoginAttempt(clientIp); if (!refund) { return NextResponse.json( diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index c2afc77b6..516f5ac11 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -80,7 +80,7 @@ export async function POST(request: NextRequest) { // private network) - never the credential check. // 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 clientIp = loginClientAddress(request, upstreamUrl); const refund = await reserveLoginAttempt(clientIp); if (!refund) { return NextResponse.json( diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts index 028d6432a..dd404e9a3 100644 --- a/app/api/auth/verify/route.ts +++ b/app/api/auth/verify/route.ts @@ -5,7 +5,7 @@ import { configManager } from '@/lib/admin/config-manager'; 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 { loginClientAddress } from '@/lib/auth/client-address'; import { reserveVerifyProbe, type BudgetRefund } from '@/lib/auth/verify-budget'; import { isStoreUnavailable } from '@/lib/auth/store-unavailable'; @@ -83,7 +83,7 @@ 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 clientIp = loginClientAddress(request, upstreamUrl); let refund: BudgetRefund | null; try { refund = await reserveVerifyProbe(clientIp); diff --git a/lib/auth/client-address.ts b/lib/auth/client-address.ts index 6de013819..c53c42635 100644 --- a/lib/auth/client-address.ts +++ b/lib/auth/client-address.ts @@ -1,5 +1,7 @@ import { BlockList, isIP } from 'node:net'; import { getClientIP } from '@/lib/admin/session'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { desktopLoginBudgetKey } from '@/lib/desktop/login-budget'; /** Addresses that name no client: loopback and unspecified. */ const NOT_A_CLIENT = new BlockList(); @@ -39,7 +41,11 @@ export function forwardedClientHeaders(clientIp: string | undefined): Record ({ + NextResponse: { + json: (data: unknown, init?: { status?: number }) => ({ + json: async () => data, + status: init?.status ?? 200, + }), + }, +})); + +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 = { allowCustomJmapEndpoint: true }; +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() })); + +const resolveJmapIdentity = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/auth/verify-jmap-auth', async (original) => ({ + ...(await original()), + resolveJmapIdentity, +})); + +const reserveLoginAttempt = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/auth/verify-budget', async (original) => ({ + ...(await original()), + reserveLoginAttempt, +})); + +// The route's first import compiles a good part of the app. +vi.setConfig({ testTimeout: 60_000 }); + +const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins'); +const LAN_SERVER = 'http://192.168.1.5:8080'; + +function loginRequest(serverUrl: string): unknown { + const headers = new Headers({ 'sec-fetch-site': 'same-origin', host: 'app.bulwark.localhost' }); + return { + method: 'POST', + headers, + nextUrl: { searchParams: new URLSearchParams(), basePath: '' }, + json: async () => ({ serverUrl, username: 'ent-alice@example.org', password: 'pw' }), + }; +} + +async function postSession(serverUrl: string) { + vi.resetModules(); + const { POST } = await import('@/app/api/auth/session/route'); + const res = (await POST(loginRequest(serverUrl) as Parameters[0])) as unknown as { + status: number; + json: () => Promise>; + }; + return { status: res.status, body: await res.json() }; +} + +beforeEach(() => { + resolveJmapIdentity.mockReset(); + resolveJmapIdentity.mockImplementation(async (url: string) => ({ serverUrl: url, accountName: 'ent-alice@example.org' })); + reserveLoginAttempt.mockReset(); + reserveLoginAttempt.mockImplementation(async () => async () => {}); + (globalThis as unknown as Record>)[TRUST_KEY] = new Set([LAN_SERVER]); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + delete (globalThis as unknown as Record)[TRUST_KEY]; +}); + +describe('POST /api/auth/session', () => { + it('a server build refuses a private URL, even with the set present', async () => { + const { status, body } = await postSession(LAN_SERVER); + expect(status).toBe(400); + expect(body.error).toBe('Server URL is not allowed'); + expect(resolveJmapIdentity).not.toHaveBeenCalled(); + }); + + it('a desktop build still refuses a private server the user did not approve', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + const { status } = await postSession('http://192.168.1.6:8080'); + expect(status).toBe(400); + expect(resolveJmapIdentity).not.toHaveBeenCalled(); + }); + + it('a desktop build reaches an approved private server, and marks it trusted', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + const { status } = await postSession(LAN_SERVER); + expect(status).toBe(200); + expect(resolveJmapIdentity).toHaveBeenCalledTimes(1); + expect(resolveJmapIdentity.mock.calls[0][3]).toMatchObject({ trusted: true }); + }); +}); + +describe('the login budget', () => { + it('a server build budgets by client address', async () => { + config.jmapServers = [{ id: 'a', url: LAN_SERVER }]; + await postSession(LAN_SERVER); + delete config.jmapServers; + expect(reserveLoginAttempt).toHaveBeenCalledWith('0.0.0.0'); + }); + + it('a desktop build budgets per mail server, so two servers do not share one', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + (globalThis as unknown as Record>)[TRUST_KEY] = new Set([LAN_SERVER, 'http://192.168.1.6:8080']); + await postSession(LAN_SERVER); + await postSession('http://192.168.1.6:8080/'); + expect(reserveLoginAttempt.mock.calls.map((c) => c[0])).toEqual([ + 'server:http://192.168.1.5:8080', + 'server:http://192.168.1.6:8080', + ]); + }); + + it('the key is no address, so nothing is forwarded upstream as the client', async () => { + const { forwardedClientHeaders } = await import('@/lib/auth/client-address'); + expect(forwardedClientHeaders('server:http://192.168.1.5:8080')).toEqual({}); + }); +}); diff --git a/lib/desktop/jmap-route.ts b/lib/desktop/jmap-route.ts new file mode 100644 index 000000000..491b2b4e3 --- /dev/null +++ b/lib/desktop/jmap-route.ts @@ -0,0 +1,42 @@ +/** + * Where the page reaches a plain http mail server in the desktop app. + * + * The app's page is https, so Chromium blocks a fetch to http://192.168.x as + * mixed content, whatever the CSP says. The embedded server forwards + * `/__jmap//...` to servers the user approved (src/server/jmap-proxy.ts in + * the desktop repo), where the id is the base64url of the server's origin. The + * real URL stays in the account registry and the cookies, since the server + * routes reach the mail server directly: only the page's own requests move. + */ + +import { IS_DESKTOP_BUILD } from './flag'; + +export function desktopJmapOriginId(origin: string): string { + const bytes = new TextEncoder().encode(origin); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** + * The base URL the page uses to talk to `serverUrl`: the same URL unless this + * is the desktop build and the server speaks plain http. + */ +export function desktopJmapBase(serverUrl: string): string { + if (!IS_DESKTOP_BUILD || typeof window === 'undefined') return serverUrl; + try { + const url = new URL(serverUrl); + if (url.protocol !== 'http:') return serverUrl; + const path = url.pathname.replace(/\/+$/, ''); + return `${window.location.origin}/__jmap/${desktopJmapOriginId(url.origin)}${path}`; + } catch { + return serverUrl; + } +} + +/** The origin part of {@link desktopJmapBase}, onto which session URLs are moved. */ +export function desktopJmapOrigin(serverUrl: string): string { + const { origin, protocol } = new URL(serverUrl); + if (!IS_DESKTOP_BUILD || typeof window === 'undefined' || protocol !== 'http:') return origin; + return `${window.location.origin}/__jmap/${desktopJmapOriginId(origin)}`; +} diff --git a/lib/desktop/login-budget.ts b/lib/desktop/login-budget.ts new file mode 100644 index 000000000..a630a8153 --- /dev/null +++ b/lib/desktop/login-budget.ts @@ -0,0 +1,17 @@ +/** + * The key the sign-in routes budget failures on, in the desktop build. + * + * There's one client on the embedded server, so a client key would make every + * account on the machine share five failures per 15 minutes: a stale password + * on one server would lock sign-in on the others. What the budget protects is + * the mail server's failure allowance for this address, so it counts per + * server. The value is no IP address, so forwardedClientHeaders passes nothing + * upstream. + */ +export function desktopLoginBudgetKey(serverUrl: unknown): string { + try { + return `server:${new URL(String(serverUrl)).origin}`; + } catch { + return 'server:invalid'; + } +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 6d35dcdfa..8554ae898 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,4 +1,6 @@ import { recordJmapCall, reportJmapFailure } from "@/lib/error-reporting/client"; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { desktopJmapBase, desktopJmapOrigin } from '@/lib/desktop/jmap-route'; import { advertisesMailboxMetadata, bulwarkEntries, escapeMetadataPathSegment, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataPatchError, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata"; import { IncompleteDeleteError } from "./delete-error"; import { downloadAllowed, saveBlobAs, type DownloadKind } from "@/lib/download-guard"; @@ -1887,7 +1889,7 @@ export class JMAPClient implements IJMAPClient { const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; response = await this.timedFetch(url, init, retryHeaders, timeoutMs); } - } else if (this.authMode === 'basic' && !this.reconnecting && url !== `${this.serverUrl}/.well-known/jmap`) { + } else if (this.authMode === 'basic' && !this.reconnecting && url !== this.discoveryUrl()) { // JMAP session may have expired - re-establish and retry once this.reconnecting = true; try { @@ -1937,7 +1939,7 @@ export class JMAPClient implements IJMAPClient { * and retry directly against the final URL so we can re-send Authorization. */ private async fetchSessionResponse(): Promise { - const discoveryUrl = `${this.serverUrl}/.well-known/jmap`; + const discoveryUrl = this.discoveryUrl(); const response = await this.authenticatedFetch(discoveryUrl, { method: 'GET' }); // A proxy in front of the session URL answers the header-less redirected // request with 401 instead of Stalwart's silent 200 - the credentials are @@ -1980,7 +1982,7 @@ export class JMAPClient implements IJMAPClient { async connect(): Promise { if (this.offlineOnly) throw new OfflineUnavailableError(); this.intentionallyDisconnected = false; - const sessionUrl = `${this.serverUrl}/.well-known/jmap`; + const sessionUrl = this.discoveryUrl(); try { const sessionResponse = await this.fetchSessionResponse(); @@ -2137,10 +2139,15 @@ export class JMAPClient implements IJMAPClient { this.capabilities = {}; } + /** Same as serverUrl, except for a plain http server in the desktop app (lib/desktop/jmap-route.ts). */ + private discoveryUrl(): string { + return `${desktopJmapBase(this.serverUrl)}/.well-known/jmap`; + } + private rewriteSessionUrl(url: string): string { if (!url) return url; try { - const { origin } = new URL(this.serverUrl); + const origin = desktopJmapOrigin(this.serverUrl); if (!/^(https?:)?\/\//i.test(url)) { return origin + (url.startsWith('/') ? url : '/' + url); } @@ -2173,6 +2180,14 @@ export class JMAPClient implements IJMAPClient { if (session.eventSourceUrl) { session.eventSourceUrl = rewrite(session.eventSourceUrl); } + // A download or upload origin of its own needs the CORS headers the desktop + // app adds for this account's servers. + getDesktopBridge()?.servers?.noteSession(this.serverUrl, { + apiUrl: session.apiUrl, + downloadUrl: session.downloadUrl, + uploadUrl: session.uploadUrl, + eventSourceUrl: session.eventSourceUrl, + }); } /** diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 0152db852..c9badd35e 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -20,6 +20,7 @@ */ import { IS_LITE } from '@/lib/lite'; +import { desktopJmapBase } from '@/lib/desktop/jmap-route'; import { devicesForAccount } from '@/lib/device-policy'; import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-subjects'; import { labelsPolicyFor } from '@/lib/outgoing/classification'; @@ -324,7 +325,7 @@ export function probeServer(serverUrl: string): Promise { if (running) return running; const probe = (async () => { try { - await fetch(`${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit' }); + await fetch(`${desktopJmapBase(serverUrl.replace(/\/+$/, ''))}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit' }); if (setServerDown(serverUrl, false)) scheduleFlushAll(500); return false; } catch {