diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 1002b45660..acc4482018 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -34,28 +34,31 @@ export const ALL_INTERFACES_HOST = '0.0.0.0' * authorities an all-interfaces bind is reachable by on the LAN. * @returns the addresses in interface order (possibly empty). */ -export function lanIPv4Addresses(): string[] { +function lanIPv4Addresses(): string[] { return Object.values(networkInterfaces()).flat() .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) .map(iface => iface.address) } /** - * Authorities the /api browser-trust fence must accept for one invocation: - * the machine's LAN IP literals when the effective bind is all-interfaces - * (advertised by the printed LAN URL, so they must not answer 403), followed - * by the explicit extras. Derived entries are port-less IP literals — DNS - * rebinding needs an attacker-controlled name, so an IP-literal Host is safe - * on any port, and the bound port may be OS-assigned, unknowable pre-boot. + * One LAN-trust resolution for one invocation, sampled exactly once: the + * machine's LAN IP literals when the effective bind is all-interfaces, and + * the `trustedHosts` value built from them plus the explicit extras. The + * single sample is deliberate — display must advertise only addresses the + * fence was configured with, so both read this snapshot. Derived entries are + * port-less IP literals: DNS rebinding needs an attacker-controlled name, so + * an IP-literal Host is safe on any port, and the bound port may be + * OS-assigned, unknowable pre-boot. * @param bindHost - the effective webserver bind host (CLI flag, else the yml default). * @param extra - `--trusted-host` values, in argv order. - * @returns the connection row's `trustedHosts` value (possibly empty). + * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty). */ -export function resolveTrustedHosts(bindHost: string | undefined, extra: readonly string[]): string[] { - return [ - ...bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [], - ...extra, - ] +export function resolveLanTrust( + bindHost: string | undefined, + extra: readonly string[], +): { lanAddresses: string[]; trustedHosts: string[] } { + const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [] + return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } } /** One profile-json key mapped onto a yml row's config field. */ @@ -126,6 +129,14 @@ export class AppCLIEntry { /** The root context, set by {@link run}. */ ctx!: Context + /** + * LAN IPv4 addresses sampled once at patch composition — the exact snapshot + * the /api trust fence was configured with. Display reads this instead of + * re-sampling, so the advertised LAN URL can never name an address the + * fence rejects. Empty unless the effective bind is all-interfaces. + */ + lanAddresses: readonly string[] = [] + private patches: PatchOptions[] = [] constructor(private readonly options: AppCLIEntryOptions) {} @@ -188,9 +199,10 @@ export class AppCLIEntry { if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot) // Source 2b: authorities for the /api browser-trust fence (rationale on - // resolveTrustedHosts). + // resolveLanTrust). const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host - const trustedHosts = resolveTrustedHosts(this.options.host ?? ymlHost, this.options.trustedHosts ?? []) + const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? []) + this.lanAddresses = lanAddresses if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts) // Source 3: the frontend dist — an assembly fact of this app, never yml diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 3d7fc29ab4..69e79ab5d9 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -7,7 +7,7 @@ */ import { fileURLToPath } from 'node:url' -import { ALL_INTERFACES_HOST, AppCLIEntry, lanIPv4Addresses } from './app-cli-entry.ts' +import { AppCLIEntry } from './app-cli-entry.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) @@ -48,7 +48,9 @@ export async function runWeb( void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) }) } - const lanCandidate = host === ALL_INTERFACES_HOST ? lanIPv4Addresses()[0] : undefined + // The entry's boot-time snapshot, not a fresh sample: the printed LAN URL + // must name an address the /api trust fence was configured with. + const lanCandidate = entry.lanAddresses[0] const localUrl = `http://${LOOPBACK_HOST}:${boundPort}` console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`) diff --git a/apps/cli/tests/trusted-hosts.spec.ts b/apps/cli/tests/trusted-hosts.spec.ts index 1ed0f602b8..571a9f76b7 100644 --- a/apps/cli/tests/trusted-hosts.spec.ts +++ b/apps/cli/tests/trusted-hosts.spec.ts @@ -1,7 +1,7 @@ -/** LAN-authority derivation for the /api browser-trust fence (`resolveTrustedHosts`). */ +/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { lanIPv4Addresses, resolveTrustedHosts } from '../src/app-cli-entry.ts' +import { describe, expect, it, vi } from 'vitest' +import { resolveLanTrust } from '../src/app-cli-entry.ts' vi.mock('node:os', () => ({ networkInterfaces: () => ({ @@ -19,22 +19,15 @@ vi.mock('node:os', () => ({ }), })) -afterEach(() => { vi.restoreAllMocks() }) +describe('resolveLanTrust', () => { + it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => { + const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080']) + expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7']) + expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) + }) -describe('lanIPv4Addresses', () => { - it('returns only non-internal IPv4 addresses, in interface order', () => { - expect(lanIPv4Addresses()).toEqual(['192.168.1.5', '10.0.0.7']) - }) -}) - -describe('resolveTrustedHosts', () => { - it('derives port-less LAN IP literals for an all-interfaces bind, ahead of the extras', () => { - expect(resolveTrustedHosts('0.0.0.0', ['harness.internal:3080'])) - .toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) - }) - - it('derives nothing for a loopback or unresolved bind — extras alone stand', () => { - expect(resolveTrustedHosts('127.0.0.1', [])).toEqual([]) - expect(resolveTrustedHosts(undefined, ['lab.internal'])).toEqual(['lab.internal']) + it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => { + expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] }) + expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) }) })