fix: compile the desktop trust path out of every other build, and fail a build that keeps it

This commit is contained in:
Linus Rath
2026-11-03 06:55:22 +01:00
parent a6df6c4aac
commit 7ac582f803
8 changed files with 60 additions and 33 deletions
+27 -23
View File
@@ -993,30 +993,34 @@ function LoginPageContent() {
};
/** The desktop build: the server's own sign-in page in the system browser. */
// Spelled out, not IS_DESKTOP_BUILD: only this form keeps the route out of other
// builds' bundles, which Lite's check on API strings relies on.
const handleDesktopBrowserSignIn = async () => {
const target = jmapEndpoint.trim();
if (!target) {
setDesktopNotice(tDesktop("server_needed"));
return;
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1') {
const target = jmapEndpoint.trim();
if (!target) {
setDesktopNotice(tDesktop("server_needed"));
return;
}
setDesktopNotice(null);
setDesktopWaiting(true);
const prefix = getPathPrefix(params.locale as string);
const outcome = await runDesktopBrowserSignIn({
serverUrl: target,
callbackPath: `${prefix}/${params.locale}/auth/callback`,
slot: useAccountStore.getState().getNextCookieSlot(),
remember: persistProviderSession,
addAccount: isAddAccountMode,
});
if (outcome.kind === "callback") {
window.location.href = outcome.callbackUrl;
return;
}
setDesktopWaiting(false);
if (outcome.kind === "unavailable") setDesktopNotice(tDesktop("browser_unavailable"));
else if (outcome.kind === "declined") setDesktopNotice(tDesktop("server_declined"));
else if (outcome.kind === "failed") setDesktopNotice(tDesktop("browser_failed"));
}
setDesktopNotice(null);
setDesktopWaiting(true);
const prefix = getPathPrefix(params.locale as string);
const outcome = await runDesktopBrowserSignIn({
serverUrl: target,
callbackPath: `${prefix}/${params.locale}/auth/callback`,
slot: useAccountStore.getState().getNextCookieSlot(),
remember: persistProviderSession,
addAccount: isAddAccountMode,
});
if (outcome.kind === "callback") {
window.location.href = outcome.callbackUrl;
return;
}
setDesktopWaiting(false);
if (outcome.kind === "unavailable") setDesktopNotice(tDesktop("browser_unavailable"));
else if (outcome.kind === "declined") setDesktopNotice(tDesktop("server_declined"));
else if (outcome.kind === "failed") setDesktopNotice(tDesktop("browser_failed"));
};
const handleSubmit = async (e: React.FormEvent) => {
@@ -1858,7 +1862,7 @@ function LoginPageContent() {
</>
)}
{IS_DESKTOP_BUILD && !hasServerList && browserSignInAvailable() && (
{process.env.NEXT_PUBLIC_BULWARK_DESKTOP === "1" && !hasServerList && browserSignInAvailable() && (
<Button
type="button"
variant="outline"
+2 -1
View File
@@ -173,7 +173,8 @@ export async function POST(request: NextRequest) {
resolvedServerId = matchedEntry.id;
} else if (configuredServerUrl && !customRequested) {
upstreamUrl = configuredServerUrl;
} else if (IS_DESKTOP_BUILD && isDesktopTrustedServer(serverUrl)) {
// Spelled out as in lib/admin/jmap-servers.ts, so other builds don't hold it.
} else if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && isDesktopTrustedServer(serverUrl)) {
upstreamUrl = serverUrl;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
+7
View File
@@ -39,6 +39,13 @@ describe('the desktop trust path', () => {
expect(guardStandalone(dir, { desktopBuild: false })).toEqual(['.next/server/chunks/123.js (desktop trust path in a server build)']);
});
it('fails a server build that still reads the flag at runtime', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.next/server/chunks/9.js', 'let a="1"===process.env.NEXT_PUBLIC_BULWARK_DESKTOP');
expect(guardStandalone(dir, { desktopBuild: false })).toHaveLength(1);
});
it('passes the desktop build itself', () => {
const dir = tempDir();
put(dir, 'server.js');
+4 -4
View File
@@ -1,7 +1,6 @@
/**
* 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';
@@ -226,9 +225,10 @@ 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);
// A server the user approved in the desktop app. Spelled out, not IS_DESKTOP_BUILD:
// only this form lets the bundler drop the branch and the module behind it, so
// that no other build holds this path at all (scripts/standalone-guard.mjs checks).
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && isDesktopTrustedServer(requestedUrl)) return trimUrl(requestedUrl);
// No match - caller decides whether to honor the request anyway (e.g. when
// allowCustomJmapEndpoint is enabled).
return null;
+8
View File
@@ -104,6 +104,14 @@ describe('the flag is fixed at build time', () => {
expect(source).toContain("process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1'");
});
it('the trust check uses the form the bundler can drop', () => {
for (const file of ['lib/admin/jmap-servers.ts', 'app/api/auth/totp-token-exchange/route.ts']) {
const source = readFileSync(path.join(ROOT, file), 'utf8');
expect(source, file).toMatch(/process\.env\.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && isDesktopTrustedServer/);
expect(source, file).not.toMatch(/IS_DESKTOP_BUILD && isDesktopTrustedServer/);
}
});
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');
+3 -2
View File
@@ -92,9 +92,10 @@ export type DesktopPluginInstallResult =
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.
* and hash checks, after a native prompt. No version means the latest. The
* renderer never holds an admin session. Standalone only.
*/
install: (slug: string, version: string) => Promise<DesktopPluginInstallResult>;
install: (slug: string, version?: string) => Promise<DesktopPluginInstallResult>;
}
export interface BulwarkDesktopBridge {
+3
View File
@@ -7,6 +7,9 @@
/** The marker scripts/standalone-guard.mjs looks for in a non-desktop build. */
export const DESKTOP_TRUST_MARKER = 'bulwark.desktop.trustedJmapOrigins';
/** A read of the flag that survived compiling: then setting it at runtime would work. */
export const DESKTOP_FLAG_READ = 'NEXT_PUBLIC_BULWARK_DESKTOP';
export function desktopBuildFlag(env = process.env) {
return env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? '1' : '';
}
+6 -3
View File
@@ -18,7 +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';
import { DESKTOP_FLAG_READ, 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']);
@@ -154,8 +154,11 @@ export function findDesktopTrustPath(standaloneDir) {
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)`);
} else if (entry.name.endsWith('.js')) {
const text = readFileSync(full, 'utf8');
if (text.includes(DESKTOP_TRUST_MARKER) || text.includes(DESKTOP_FLAG_READ)) {
found.push(`${toPosix(path.relative(standaloneDir, full))} (desktop trust path in a server build)`);
}
}
}
};