mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
feat: let the desktop app reach LAN and plain http mail servers the user approved
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user