From a30f989c578eee8015d9340d773bd367ae991cd8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:32:27 +0100 Subject: [PATCH] feat: add a desktop build of the webmail that no server build can switch on --- .../settings/account-security-settings.tsx | 11 +- lib/__tests__/standalone-guard.test.ts | 27 ++++ lib/account-utils.ts | 5 + lib/admin/jmap-servers.ts | 6 + lib/desktop/__tests__/trust-flag.test.ts | 112 +++++++++++++++++ lib/desktop/blocked-routes.ts | 10 ++ lib/desktop/bridge.ts | 20 +++ lib/desktop/flag.ts | 17 +++ lib/desktop/trusted-servers.ts | 33 +++++ lib/desktop/types.ts | 117 ++++++++++++++++++ lib/theme-color-meta.ts | 12 ++ lib/web-push.ts | 3 + next.config.ts | 4 + package.json | 1 + proxy.ts | 6 + scripts/build-desktop.mjs | 22 ++++ scripts/desktop-build-flag.mjs | 12 ++ scripts/standalone-guard.mjs | 27 +++- 18 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 lib/desktop/__tests__/trust-flag.test.ts create mode 100644 lib/desktop/blocked-routes.ts create mode 100644 lib/desktop/bridge.ts create mode 100644 lib/desktop/flag.ts create mode 100644 lib/desktop/trusted-servers.ts create mode 100644 lib/desktop/types.ts create mode 100644 scripts/build-desktop.mjs create mode 100644 scripts/desktop-build-flag.mjs diff --git a/components/settings/account-security-settings.tsx b/components/settings/account-security-settings.tsx index acee9fd75..184c979e3 100644 --- a/components/settings/account-security-settings.tsx +++ b/components/settings/account-security-settings.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useMemo, useCallback, useId, useRef } from 'react'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; import { useParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import QRCode from 'qrcode'; @@ -1569,8 +1570,12 @@ export function AccountSecuritySettings() { {!IS_LITE ? (
- -
+ {!IS_DESKTOP_BUILD && ( + <> + +
+ + )}
) : ( @@ -1610,7 +1615,7 @@ export function AccountSecuritySettings() { description={withoutPassword ? t('description') : t('sections.devices_description')} >
- {!IS_LITE && ( + {!IS_LITE && !IS_DESKTOP_BUILD && ( <>
diff --git a/lib/__tests__/standalone-guard.test.ts b/lib/__tests__/standalone-guard.test.ts index dbc69e792..f982ecb14 100644 --- a/lib/__tests__/standalone-guard.test.ts +++ b/lib/__tests__/standalone-guard.test.ts @@ -31,6 +31,33 @@ afterEach(() => { while (temps.length) rmSync(temps.pop()!, { recursive: true, force: true }); }); +describe('the desktop trust path', () => { + it('fails a server build that still holds it', () => { + const dir = tempDir(); + put(dir, 'server.js'); + put(dir, '.next/server/chunks/123.js', 'holder[Symbol.for("bulwark.desktop.trustedJmapOrigins")]'); + expect(guardStandalone(dir, { desktopBuild: false })).toEqual(['.next/server/chunks/123.js (desktop trust path in a server build)']); + }); + + it('passes the desktop build itself', () => { + const dir = tempDir(); + put(dir, 'server.js'); + put(dir, '.next/server/chunks/123.js', 'holder[Symbol.for("bulwark.desktop.trustedJmapOrigins")]'); + expect(guardStandalone(dir, { desktopBuild: true })).toEqual([]); + }); + + it('fails the command line check on a server build', () => { + const dir = tempDir(); + put(dir, 'server.js'); + put(dir, '.next/server/chunks/123.js', 'bulwark.desktop.trustedJmapOrigins'); + const env = { ...process.env }; + delete env.NEXT_PUBLIC_BULWARK_DESKTOP; + const run = spawnSync(process.execPath, [SCRIPT, dir], { encoding: 'utf8', env }); + expect(run.status).toBe(1); + expect(run.stderr).toContain('desktop trust path'); + }); +}); + describe('standalone guard check', () => { it('fails the build on a planted signing key', () => { const dir = tempDir(); diff --git a/lib/account-utils.ts b/lib/account-utils.ts index c6cb10965..301333af0 100644 --- a/lib/account-utils.ts +++ b/lib/account-utils.ts @@ -1,3 +1,5 @@ +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; + /** * Utilities for multi-account support: * - Account ID generation @@ -136,6 +138,9 @@ export function isHttp2Available(): boolean { * once HTTP/2+ is observed, otherwise returns {@link MAX_ACCOUNTS_HTTP1}. */ export function getMaxAccounts(): number { + // The cap is for the push streams sharing one http/1.1 origin. The embedded + // server speaks http/1.1, but each account streams from its own mail server. + if (IS_DESKTOP_BUILD) return MAX_ACCOUNT_SLOTS; return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1; } diff --git a/lib/admin/jmap-servers.ts b/lib/admin/jmap-servers.ts index 67319b54d..fa054875a 100644 --- a/lib/admin/jmap-servers.ts +++ b/lib/admin/jmap-servers.ts @@ -1,6 +1,9 @@ /** * Multi-server JMAP support: schema, parsing, lookup, and redaction helpers. */ +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers'; + export interface JmapServerOAuthConfig { clientId?: string; @@ -223,6 +226,9 @@ export function resolveTrustedJmapUrl( } const matched = servers.find((s) => normalizeUrl(s.url) === target); if (matched) return matched.url; + // A server the user approved in the desktop app. Compiled out of every other + // build, so nothing at runtime can open this path on a server. + if (IS_DESKTOP_BUILD && isDesktopTrustedServer(requestedUrl)) return trimUrl(requestedUrl); // No match - caller decides whether to honor the request anyway (e.g. when // allowCustomJmapEndpoint is enabled). return null; diff --git a/lib/desktop/__tests__/trust-flag.test.ts b/lib/desktop/__tests__/trust-flag.test.ts new file mode 100644 index 000000000..202a4b1ff --- /dev/null +++ b/lib/desktop/__tests__/trust-flag.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { build } from 'esbuild'; +import { desktopBuildFlag } from '../../../scripts/desktop-build-flag.mjs'; + +const ROOT = path.resolve(__dirname, '../../..'); +const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins'); +const PRIVATE_SERVER = 'http://192.168.1.5:8080'; + +type Holder = Record | undefined>; + +function trust(...origins: string[]) { + (globalThis as unknown as Holder)[TRUST_KEY] = new Set(origins); +} + +async function loadResolver() { + vi.resetModules(); + return (await import('@/lib/admin/jmap-servers')).resolveTrustedJmapUrl; +} + +beforeEach(() => { + delete (globalThis as unknown as Holder)[TRUST_KEY]; +}); + +afterEach(() => { + vi.unstubAllEnvs(); + delete (globalThis as unknown as Holder)[TRUST_KEY]; +}); + +describe('the trusted set, server build', () => { + it('refuses a private URL even when the set holds it', async () => { + trust('http://192.168.1.5:8080'); + const resolve = await loadResolver(); + expect(resolve(PRIVATE_SERVER, '', [])).toBeNull(); + }); +}); + +describe('the trusted set, desktop build', () => { + beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + }); + + it('accepts a server main approved', async () => { + trust('http://192.168.1.5:8080'); + const resolve = await loadResolver(); + expect(resolve(`${PRIVATE_SERVER}/`, '', [])).toBe(PRIVATE_SERVER); + }); + + it('does not trust everything: another private server stays out', async () => { + trust('http://192.168.1.5:8080'); + const resolve = await loadResolver(); + expect(resolve('http://192.168.1.6:8080', '', [])).toBeNull(); + expect(resolve('http://192.168.1.5:9999', '', [])).toBeNull(); + expect(resolve('http://169.254.169.254', '', [])).toBeNull(); + }); + + it('trusts nothing before main has sent a set', async () => { + const resolve = await loadResolver(); + expect(resolve(PRIVATE_SERVER, '', [])).toBeNull(); + }); + + it('ignores a URL with credentials in it', async () => { + trust('http://192.168.1.5:8080'); + const resolve = await loadResolver(); + expect(resolve('http://a:b@192.168.1.5:8080', '', [])).toBeNull(); + }); +}); + +describe('the flag is fixed at build time', () => { + /** Compiles lib/desktop/flag.ts the way next.config.ts `env` does and runs it. */ + async function flagOfBuild(buildEnv: NodeJS.ProcessEnv, runtimeEnv: NodeJS.ProcessEnv): Promise { + const result = await build({ + entryPoints: [path.join(ROOT, 'lib/desktop/flag.ts')], + bundle: true, + write: false, + format: 'cjs', + platform: 'node', + define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(desktopBuildFlag(buildEnv)) }, + }); + const run = new Function('module', 'exports', 'process', result.outputFiles[0].text); + const module = { exports: {} as { IS_DESKTOP_BUILD?: boolean } }; + run(module, module.exports, { env: runtimeEnv }); + return module.exports.IS_DESKTOP_BUILD === true; + } + + it('a runtime env var cannot switch a server build on', async () => { + expect(await flagOfBuild({}, { NEXT_PUBLIC_BULWARK_DESKTOP: '1' })).toBe(false); + }); + + it('a desktop build is on without any runtime env var', async () => { + expect(await flagOfBuild({ NEXT_PUBLIC_BULWARK_DESKTOP: '1' }, {})).toBe(true); + }); + + it('next.config.ts always inlines the flag, so an unset one is no runtime read', () => { + const config = readFileSync(path.join(ROOT, 'next.config.ts'), 'utf8'); + expect(config).toMatch(/NEXT_PUBLIC_BULWARK_DESKTOP: desktopBuildFlag\(\)/); + expect(desktopBuildFlag({})).toBe(''); + }); + + it('flag.ts reads the variable in the one form the compiler replaces', () => { + const source = readFileSync(path.join(ROOT, 'lib/desktop/flag.ts'), 'utf8'); + expect(source).toContain("process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1'"); + }); + + it('no image build sets it', () => { + for (const file of ['Dockerfile', 'Dockerfile.lite']) { + expect(readFileSync(path.join(ROOT, file), 'utf8')).not.toContain('NEXT_PUBLIC_BULWARK_DESKTOP'); + } + }); +}); diff --git a/lib/desktop/blocked-routes.ts b/lib/desktop/blocked-routes.ts new file mode 100644 index 000000000..093b38cf0 --- /dev/null +++ b/lib/desktop/blocked-routes.ts @@ -0,0 +1,10 @@ +/** + * Routes with no meaning in the desktop build: pairing a phone and the mobile + * hand-off both hand out `https://app.bulwark.localhost`, which nothing else + * can reach, and the app has no use for impersonation. proxy.ts answers 404. + */ +const BLOCKED = /^\/api\/auth\/(pair|handoff|impersonate)(\/|$)/; + +export function isDesktopBlockedRoute(pathname: string): boolean { + return BLOCKED.test(pathname); +} diff --git a/lib/desktop/bridge.ts b/lib/desktop/bridge.ts new file mode 100644 index 000000000..852ebbfbe --- /dev/null +++ b/lib/desktop/bridge.ts @@ -0,0 +1,20 @@ +/** + * Finds the desktop app's bridge. Everything here is feature detected: in a + * browser there's no bridge, and a newer bridge version than this code knows + * is treated as absent rather than guessed at. + */ + +import { DESKTOP_BRIDGE_VERSION, type BulwarkDesktopBridge } from './types'; + +export function getDesktopBridge(): BulwarkDesktopBridge | null { + if (typeof window === 'undefined') return null; + const bridge = window.bulwarkDesktop; + if (!bridge || typeof bridge !== 'object') return null; + if (typeof bridge.version !== 'number' || bridge.version > DESKTOP_BRIDGE_VERSION) return null; + return bridge; +} + +/** True in any window of the desktop app, standalone or on an instance. */ +export function isDesktopApp(): boolean { + return getDesktopBridge() !== null; +} diff --git a/lib/desktop/flag.ts b/lib/desktop/flag.ts new file mode 100644 index 000000000..de72502e1 --- /dev/null +++ b/lib/desktop/flag.ts @@ -0,0 +1,17 @@ +/** + * The desktop app's own webmail build (`npm run build:desktop`). + * + * A build-time constant, inlined through next.config.ts `env` into client and + * server bundles alike, like `IS_LITE`. A server admin can't switch it on: in + * any other build the value is compiled in as empty, and the bundler drops + * the branches. Never read this flag through a runtime environment variable. + * + * What it unlocks, and only in that build: + * - login routes accept the servers the user approved in the app + * (`isDesktopTrustedServer`, the list comes from the Electron main process) + * - sign-in through the system browser for any server + * - a per-slot record of the upstream for refresh tokens + * - a login budget per mail server rather than per client address + * - pairing and hand-off routes are gone + */ +export const IS_DESKTOP_BUILD = process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1'; diff --git a/lib/desktop/trusted-servers.ts b/lib/desktop/trusted-servers.ts new file mode 100644 index 000000000..e2cf034b5 --- /dev/null +++ b/lib/desktop/trusted-servers.ts @@ -0,0 +1,33 @@ +/** + * The mail servers the user approved in the desktop app. + * + * The Electron main process owns this list and sends it to the embedded server + * at start and on every change (src/server/host.ts in the desktop repo sets it + * on globalThis, which all of Next's bundles share). Nothing in the renderer + * or the environment can write it, so script in a page can't aim the login + * routes at the LAN. + * + * Callers guard this with IS_DESKTOP_BUILD, so a server build compiles the + * whole thing away. scripts/standalone-guard.mjs checks that it did. + */ + +type TrustedOrigins = ReadonlySet; + +function originOf(url: string): string | null { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + if (parsed.username || parsed.password) return null; + return parsed.origin; + } catch { + return null; + } +} + +export function isDesktopTrustedServer(url: string | null | undefined): boolean { + if (!url) return false; + const origin = originOf(url); + if (!origin) return false; + const holder = globalThis as unknown as Record; + return holder[Symbol.for('bulwark.desktop.trustedJmapOrigins')]?.has(origin) === true; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts new file mode 100644 index 000000000..7ae919aa6 --- /dev/null +++ b/lib/desktop/types.ts @@ -0,0 +1,117 @@ +/** + * The desktop app's preload bridge as the webmail sees it. Feature checks ask + * `window.bulwarkDesktop?.`; in a browser the object isn't there. + * + * The shell side lives in the desktop repo (src/shared/bridge.ts), a copy of + * this contract. Add a member here and there together. A breaking change bumps + * `version`; adding a member doesn't. + */ + +export const DESKTOP_BRIDGE_VERSION = 1; + +export type DesktopWindowType = 'main' | 'message' | 'composer' | 'settings'; + +export interface DesktopWindowInfo { + id: number; + type: DesktopWindowType; + title: string; + focused: boolean; +} + +export interface DesktopWindowsBridge { + /** + * Opens a pop-out window itself. The name is the window's identity: opening + * the same name again focuses the window that has it. Left out, the page uses + * window.open with a features string, which the shell's window-open handler + * sees too. + */ + open?: (url: string, options: { name: string; width: number; height: number }) => void; + /** One window per message: opening it again focuses it. */ + openMessage?: (target: { url: string; emailId: string }) => void; + openComposer?: (target: { url: string; name?: string }) => void; + /** Focuses a window from `list()`, or the main window with no id. */ + focus?: (id?: number) => void; + list?: () => Promise; + /** Fires when a window opens, closes, retitles or takes the focus. Returns the unsubscribe. */ + onChanged?: (listener: (windows: DesktopWindowInfo[]) => void) => () => void; +} + +export interface DesktopThemeBridge { + /** The page's colours, for title bar buttons and the first frame of new windows. */ + set: (theme: { dark: boolean; background: string; foreground?: string }) => void; + /** Fires when the OS switches between light and dark. Returns the unsubscribe. */ + onSystemChange?: (listener: (system: { dark: boolean; highContrast: boolean }) => void) => () => void; +} + +export interface DesktopMenuBridge { + /** + * What the app menu shows as enabled or checked, by command id. The shell + * keeps the last state per window and applies it when that window has focus. + */ + setState: (state: Record) => void; +} + +export interface DesktopShellBridge { + /** http, https and mailto only. */ + openExternal: (url: string) => void; +} + +export type DesktopServerPrepareResult = + | { ok: true; approved: boolean } + | { ok: false; reason: 'declined' | 'invalid' }; + +export interface DesktopServersBridge { + /** + * Call before signing in to a server. Asks the user first when it is on the + * LAN or plain http, and lets the app reach it. Standalone mode only. + */ + prepare: (serverUrl: string) => Promise; + /** The origins a session names, for the CORS headers the shell adds for them. */ + noteSession: ( + serverUrl: string, + urls: { apiUrl?: string; downloadUrl?: string; uploadUrl?: string; eventSourceUrl?: string }, + ) => void; +} + +export type DesktopBrowserSignInResult = + | { ok: true; code: string; state: string } + | { ok: false; error: string }; + +export interface DesktopAuthBridge { + /** Starts a short-lived loopback listener for one sign-in. */ + beginBrowserSignIn: () => Promise<{ flowId: string; redirectUri: string }>; + /** Opens the system browser on `authorizeUrl` and waits for the redirect. */ + completeBrowserSignIn: (flowId: string, authorizeUrl: string) => Promise; + cancelBrowserSignIn: (flowId: string) => void; +} + +export type DesktopPluginInstallResult = + | { ok: true; id: string; version: string } + | { ok: false; error: string; code?: string }; + +export interface DesktopPluginsBridge { + /** + * Installs from the directory through the local admin API, with its signature + * and hash checks. The renderer never holds an admin session. Standalone only. + */ + install: (slug: string, version: string) => Promise; +} + +export interface BulwarkDesktopBridge { + version: number; + platform: 'win32' | 'darwin' | 'linux'; + mode?: 'standalone' | 'instance'; + windows?: DesktopWindowsBridge; + theme?: DesktopThemeBridge; + menu?: DesktopMenuBridge; + shell?: DesktopShellBridge; + servers?: DesktopServersBridge; + auth?: DesktopAuthBridge; + plugins?: DesktopPluginsBridge; +} + +declare global { + interface Window { + bulwarkDesktop?: BulwarkDesktopBridge; + } +} diff --git a/lib/theme-color-meta.ts b/lib/theme-color-meta.ts index bcfd88881..dd9064e3f 100644 --- a/lib/theme-color-meta.ts +++ b/lib/theme-color-meta.ts @@ -1,3 +1,5 @@ +import { getDesktopBridge } from '@/lib/desktop/bridge'; + /** * Keeps in step with the active theme. * @@ -66,6 +68,7 @@ export function syncThemeColorMeta(): void { const run = () => { scheduled = false; writeMeta(); + tellDesktopShell(); }; if (typeof requestAnimationFrame === 'function') { @@ -75,6 +78,15 @@ export function syncThemeColorMeta(): void { } } +/** The desktop app's title bar buttons and new windows' first frame follow the page. */ +function tellDesktopShell(): void { + const theme = getDesktopBridge()?.theme; + if (!theme) return; + const background = document.querySelector('meta[name="theme-color"]')?.content; + if (!background) return; + theme.set({ dark: document.documentElement.classList.contains('dark'), background }); +} + /** Turn syncing on and take a first reading. Called by . */ export function enableThemeColorSync(): void { enabled = true; diff --git a/lib/web-push.ts b/lib/web-push.ts index f1739b441..4ab2e43de 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -5,6 +5,7 @@ // register endpoint we hit on the relay. import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { isDesktopApp } from '@/lib/desktop/bridge'; import type { AccountInfo, EmailPushConfig, Mailbox } from '@/lib/jmap/types'; import { isJmapSetError, jmapErrorType } from '@/lib/jmap/jmap-error'; import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays'; @@ -443,6 +444,8 @@ export function isWebPushSupported(): boolean { // No service worker is registered in the static Lite build (see // components/service-worker-registration.tsx), so push cannot be enabled. if (IS_LITE) return false; + // Electron has the API, but subscribing always fails: it has no push service. + if (isDesktopApp()) return false; return ( 'serviceWorker' in navigator && 'PushManager' in window && diff --git a/next.config.ts b/next.config.ts index 1ce8a7714..b9bfbfb35 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,7 @@ import { createHash } from "crypto"; import { readFileSync } from "fs"; import { join } from "path"; import { pruneTraces } from "./scripts/standalone-guard.mjs"; +import { desktopBuildFlag } from "./scripts/desktop-build-flag.mjs"; // Prefer an explicit build arg (passed in by CI / Docker, where .git is // excluded from the build context) and fall back to `git rev-parse` for @@ -193,6 +194,9 @@ const nextConfig: NextConfig = { NEXT_PUBLIC_BASE_PATH: basePath, NEXT_PUBLIC_DEV_MOCK_JMAP: process.env.DEV_MOCK_JMAP ?? "", NEXT_PUBLIC_BULWARK_LITE: isLite ? "1" : "", + // Always a literal, so setting the variable when starting a server build + // does nothing (lib/desktop/flag.ts). + NEXT_PUBLIC_BULWARK_DESKTOP: desktopBuildFlag(), }, }; diff --git a/package.json b/package.json index 52adefb78..f9ca56924 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dev": "node scripts/build-pst-worker.mjs && node scripts/build-store-worker.mjs && node scripts/build-plugin-sandbox.mjs --watch -- next dev --turbopack", "build": "node scripts/build-pst-worker.mjs && node scripts/build-store-worker.mjs && node scripts/build-plugin-sandbox.mjs && next build --turbopack && node scripts/standalone-guard.mjs", "build:lite": "node scripts/lite/build.mjs", + "build:desktop": "node scripts/build-desktop.mjs", "lite:serve": "node scripts/lite/serve.mjs out 4173", "test:lite-smoke": "playwright test -c scripts/lite/smoke/playwright.config.ts", "test:lite-smoke:stalwart": "playwright test -c scripts/lite/smoke/playwright.stalwart.config.ts", diff --git a/proxy.ts b/proxy.ts index 4e47be5e8..92c1719e7 100644 --- a/proxy.ts +++ b/proxy.ts @@ -16,6 +16,8 @@ import { getWopiEditorOrigins } from "./lib/wopi/discovery"; import { detectSetupState } from "./lib/setup/state"; import { isServedOverHttps } from "./lib/security/request-protocol"; import { bootGateResponse } from "./lib/shared-store/boot-gate"; +import { IS_DESKTOP_BUILD } from "./lib/desktop/flag"; +import { isDesktopBlockedRoute } from "./lib/desktop/blocked-routes"; const intlMiddleware = createIntlMiddleware(routing); @@ -268,6 +270,10 @@ export async function proxy(request: NextRequest) { return NextResponse.json({ error: "Cross-origin request rejected" }, { status: 403 }); } + if (IS_DESKTOP_BUILD && isDesktopBlockedRoute(pathname)) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + if (PROXY_SKIP_PATTERN.test(pathname) || isStaticAssetPath(pathname)) { // No page CSP here, but what comes back can still be a document: the // not-found shell for a missing /foo.html or an unknown /api route. It diff --git a/scripts/build-desktop.mjs b/scripts/build-desktop.mjs new file mode 100644 index 000000000..5240927ba --- /dev/null +++ b/scripts/build-desktop.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +// The webmail build the desktop app embeds: `npm run build:desktop`. It's the +// regular standalone build with NEXT_PUBLIC_BULWARK_DESKTOP compiled in, using +// webpack (a third of Turbopack's server size, and its hashed external links +// don't relocate). No other build sets the flag: repos/desktop's +// scripts/fetch-webmail.mjs refuses a tree without it. + +import { spawnSync } from 'node:child_process'; + +const env = { ...process.env, NEXT_PUBLIC_BULWARK_DESKTOP: '1', NEXT_TELEMETRY_DISABLED: '1' }; +const isWindows = process.platform === 'win32'; + +for (const [command, ...args] of [ + ['node', 'scripts/build-pst-worker.mjs'], + ['node', 'scripts/build-store-worker.mjs'], + ['node', 'scripts/build-plugin-sandbox.mjs'], + ['npx', 'next', 'build', '--webpack'], + ['node', 'scripts/standalone-guard.mjs'], +]) { + const run = spawnSync(command, args, { env, stdio: 'inherit', shell: isWindows }); + if (run.status !== 0) process.exit(run.status ?? 1); +} diff --git a/scripts/desktop-build-flag.mjs b/scripts/desktop-build-flag.mjs new file mode 100644 index 000000000..a6f52d122 --- /dev/null +++ b/scripts/desktop-build-flag.mjs @@ -0,0 +1,12 @@ +// The value next.config.ts inlines as NEXT_PUBLIC_BULWARK_DESKTOP. +// +// Always defined, so every build compiles `process.env.NEXT_PUBLIC_BULWARK_DESKTOP` +// to a literal. Left undefined, Next would leave the read in the server bundle, +// and setting the variable when starting a server build would turn the flag on. + +/** The marker scripts/standalone-guard.mjs looks for in a non-desktop build. */ +export const DESKTOP_TRUST_MARKER = 'bulwark.desktop.trustedJmapOrigins'; + +export function desktopBuildFlag(env = process.env) { + return env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? '1' : ''; +} diff --git a/scripts/standalone-guard.mjs b/scripts/standalone-guard.mjs index 80aaff1de..d44165b9e 100644 --- a/scripts/standalone-guard.mjs +++ b/scripts/standalone-guard.mjs @@ -18,6 +18,7 @@ import { existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { DESKTOP_TRUST_MARKER, desktopBuildFlag } from './desktop-build-flag.mjs'; /** Top-level project entries the running server needs. Traces drop the rest. */ const TRACE_ALLOWED = new Set(['node_modules', '.next', 'public', 'locales', 'package.json', 'VERSION']); @@ -141,10 +142,32 @@ export function removeCopiedEnvFiles(standaloneDir) { return removed; } +/** + * Server bundles that still hold the desktop app's trust path. A server build + * compiles it away (lib/desktop/flag.ts); finding it means the flag leaked into + * this build or stopped being inlined, and a visitor could reach private hosts. + */ +export function findDesktopTrustPath(standaloneDir) { + const found = []; + const visit = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== 'node_modules') visit(full); + } else if (entry.name.endsWith('.js') && readFileSync(full, 'utf8').includes(DESKTOP_TRUST_MARKER)) { + found.push(`${toPosix(path.relative(standaloneDir, full))} (desktop trust path in a server build)`); + } + } + }; + visit(standaloneDir); + return found.sort(); +} + /** Cleans and checks a standalone tree. Returns what is still wrong with it. */ -export function guardStandalone(standaloneDir) { +export function guardStandalone(standaloneDir, { desktopBuild = desktopBuildFlag() === '1' } = {}) { removeCopiedEnvFiles(standaloneDir); - return findForbidden(standaloneDir); + const forbidden = findForbidden(standaloneDir); + return desktopBuild ? forbidden : [...forbidden, ...findDesktopTrustPath(standaloneDir)]; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {