feat: let the desktop app reach LAN and plain http mail servers the user approved

This commit is contained in:
Linus Rath
2026-11-03 06:32:28 +01:00
parent a30f989c57
commit e65f7fe31f
9 changed files with 239 additions and 11 deletions
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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(
+2 -2
View File
@@ -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);
+8 -2
View File
@@ -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<str
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 {
/**
* The client address of `request` as the sign-in routes forward and budget it.
* The desktop build has one client, so there it counts per mail server instead.
*/
export function loginClientAddress(request: Request, serverUrl?: unknown): string {
if (IS_DESKTOP_BUILD) return desktopLoginBudgetKey(serverUrl);
return getClientIP(request);
}
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* What the login routes do with a private server, with and without the desktop
* build, and whose budget a wrong password spends.
*/
vi.mock('next/server', () => ({
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<string, unknown> = { 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<object>()),
resolveJmapIdentity,
}));
const reserveLoginAttempt = vi.hoisted(() => vi.fn());
vi.mock('@/lib/auth/verify-budget', async (original) => ({
...(await original<object>()),
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<typeof POST>[0])) as unknown as {
status: number;
json: () => Promise<Record<string, unknown>>;
};
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<symbol, ReadonlySet<string>>)[TRUST_KEY] = new Set([LAN_SERVER]);
});
afterEach(() => {
vi.unstubAllEnvs();
delete (globalThis as unknown as Record<symbol, unknown>)[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<symbol, ReadonlySet<string>>)[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({});
});
});
+42
View File
@@ -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/<id>/...` 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)}`;
}
+17
View File
@@ -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';
}
}
+19 -4
View File
@@ -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<string, string>, '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<Response> {
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<void> {
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,
});
}
/**
+2 -1
View File
@@ -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<boolean> {
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 {