diff --git a/apps/desktop/renderer.html b/apps/desktop/renderer.html index 555db588a..9e64e557f 100644 --- a/apps/desktop/renderer.html +++ b/apps/desktop/renderer.html @@ -4,7 +4,7 @@ diff --git a/apps/desktop/src/ipc-lifecycle.test.ts b/apps/desktop/src/ipc-lifecycle.test.ts index 9cda8eeba..2cf203860 100644 --- a/apps/desktop/src/ipc-lifecycle.test.ts +++ b/apps/desktop/src/ipc-lifecycle.test.ts @@ -142,6 +142,63 @@ describe('desktop IPC shutdown gate', () => { ]); }); + it('schedules a replacement document before publishing a newly admitted renderer endpoint', async () => { + const handlers = new Map unknown>(); + const ipcMain = { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain; + const profile = { + id: 'profile-loopback', label: 'Loopback', apiBaseUrl: 'http://127.42.7.9:43127', + createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z', + }; + let listCalls = 0; + const credentials = { + listProfiles: async () => ({ + profiles: listCalls++ === 0 ? [] : [profile], + activeProfileId: listCalls === 1 ? null : profile.id, + }), + activate: async () => ({ + status: 'ready', profileId: profile.id, transportScope: 'scope-loopback', identityEpoch: 'L'.repeat(22), + }), + } as unknown as DesktopCredentialService; + const admitted: string[] = []; + let reloads = 0; + registerIpcHandlers({ + app: { getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true } as unknown as App, + ipcMain, + profiles: {} as ProfileStore, + credentials, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession: { clearStorageData: async () => undefined } as unknown as Session, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + admitRendererEndpoint: origin => { + admitted.push(origin); + return true; + }, + scheduleRendererPolicyReload: () => { reloads += 1; }, + }); + const event = { senderFrame: { url: 'propr-renderer://app/index.html' } } as unknown as IpcMainInvokeEvent; + + const activated = await Promise.resolve( + handlers.get(IPC_CHANNELS.connectionActivate)!(event, 'T'.repeat(43)), + ); + + assert.deepEqual(admitted, [profile.apiBaseUrl]); + assert.equal(reloads, 1); + assert.deepEqual(activated, { + status: 'ready', + profileId: profile.id, + transportScope: 'scope-loopback', + identityEpoch: 'L'.repeat(22), + rendererReloadRequired: true, + }); + }); + it('rejects activation and discards its exact scope when origin storage clearing fails', async () => { const handlers = new Map unknown>(); const ipcMain = { diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index e2cda64ff..164271e8e 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -30,6 +30,10 @@ interface RegisterIpcOptions { devServerUrl: string | undefined; packagedRendererUrl: string; openExternal(url: string): Promise; + /** Extend the next packaged document policy after a validated activation. */ + admitRendererEndpoint?(origin: string): boolean; + /** Reload after the activation response is delivered when its endpoint needs a new document policy. */ + scheduleRendererPolicyReload?(): void; /** @internal Deterministic admitted-work accounting for lifecycle proof. */ observeInvocation?(phase: 'entry' | 'exit', channel: string): void; /** @internal Fixed, secret-free packaged Connect acceptance evidence. */ @@ -165,6 +169,10 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcH .find(profile => profile.id === after.activeProfileId)?.apiBaseUrl; const origins = [previousOrigin, activatedOrigin].filter(origin => origin !== undefined); await clearDesktopInstanceCookies(options.desktopSession, origins); + if (activatedOrigin && options.admitRendererEndpoint?.(activatedOrigin)) { + options.scheduleRendererPolicyReload?.(); + return { ...activated, rendererReloadRequired: true as const }; + } return activated; } catch (error) { await options.credentials.discardActivation({ diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 66e1765bd..914bcbe88 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -35,12 +35,14 @@ import { } from './packaged-approval-session'; import { createDesktopShutdownCoordinator } from './shutdown'; import { + applyPackagedRendererCsp, deepLinkFromArguments, isSafeExternalUrl, isTrustedRendererUrl, normalizeApiBaseUrl, normalizeDeepLink, rendererContentSecurityPolicy, + rendererCspAllowsConnectUrl, validatedDevServerUrl, } from './security'; import { @@ -156,6 +158,23 @@ interface PackagedTransportSmoke { } let activePackagedTransportSmoke: PackagedTransportSmoke | null = null; let activePackagedConnectJourney = false; +const rendererCspApiBaseUrls = new Set(); +let loadedRendererCspApiBaseUrls = new Set(); + +const currentRendererContentSecurityPolicy = (): string => rendererContentSecurityPolicy( + !app.isPackaged, + [...rendererCspApiBaseUrls], +); + +const admitRendererCspEndpoint = (origin: string): boolean => { + if (rendererCspAllowsConnectUrl(origin, [...loadedRendererCspApiBaseUrls])) return false; + const normalized = normalizeApiBaseUrl(origin); + if (!normalized || normalized !== origin || !rendererCspAllowsConnectUrl(origin, [origin])) { + throw new Error('Renderer CSP rejected an invalid profile endpoint'); + } + rendererCspApiBaseUrls.add(origin); + return app.isPackaged; +}; interface PackagedConnectSmoke { configRoot: string; @@ -353,7 +372,7 @@ const deliverDeepLink = (value: string): void => { }; const configurePackagedRendererProtocol = (): (() => void) => { - protocol.handle(PACKAGED_RENDERER_SCHEME, request => { + protocol.handle(PACKAGED_RENDERER_SCHEME, async request => { const requestUrl = new URL(request.url); if (requestUrl.hostname !== PACKAGED_RENDERER_HOST) { return new Response(null, { status: 404 }); @@ -370,7 +389,18 @@ const configurePackagedRendererProtocol = (): (() => void) => { if (relativePath.startsWith('..') || isAbsolute(relativePath)) { return new Response(null, { status: 403 }); } - return net.fetch(pathToFileURL(filePath).href); + const response = await net.fetch(pathToFileURL(filePath).href); + if (requestedPath !== 'renderer.html' || !response.ok) return response; + const html = await response.text(); + const transformed = applyPackagedRendererCsp(html, [...rendererCspApiBaseUrls]); + loadedRendererCspApiBaseUrls = new Set(rendererCspApiBaseUrls); + const headers = new Headers(response.headers); + headers.delete('content-length'); + return new Response(transformed, { + status: response.status, + statusText: response.statusText, + headers, + }); }); return () => { void protocol.unhandle(PACKAGED_RENDERER_SCHEME); }; }; @@ -1164,6 +1194,8 @@ if (!hasSingleInstanceLock) { decrypt: value => safeStorage.decryptString(value), }; const profiles = new ProfileStore(app.getPath('userData'), productionEncryption); + const initialProfiles = await profiles.list(); + for (const profile of initialProfiles.profiles) rendererCspApiBaseUrls.add(profile.apiBaseUrl); const connectDiscovery = new DesktopConnectDiscoveryService(profiles, { supported: DESKTOP_CONNECT_DISCOVERY_PLATFORMS.has(process.platform), discover: async () => { @@ -1213,7 +1245,7 @@ if (!hasSingleInstanceLock) { connectDiscovery.snapshotIdentityClaim(profileId, origin), }); const sessionSecurity = configureDesktopSessionSecurity({ - contentSecurityPolicy: () => rendererContentSecurityPolicy(!app.isPackaged), + contentSecurityPolicy: currentRendererContentSecurityPolicy, credentials, desktopSession: session.defaultSession, enableRendererNetworkBoundary: process.platform !== 'win32', @@ -1247,6 +1279,13 @@ if (!hasSingleInstanceLock) { devServerUrl, packagedRendererUrl, openExternal: openAllowedExternalUrl, + admitRendererEndpoint: admitRendererCspEndpoint, + scheduleRendererPolicyReload: () => { + setTimeout(() => { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.reload(); + }, 0); + }, ...(journeyStages ? { reportAcceptanceJourneyStage: (stage: DesktopAcceptanceJourneyStage) => { journeyStages.record(stage); diff --git a/apps/desktop/src/security.test.ts b/apps/desktop/src/security.test.ts index 69c0f9fd8..b2c55bcb5 100644 --- a/apps/desktop/src/security.test.ts +++ b/apps/desktop/src/security.test.ts @@ -4,6 +4,7 @@ import { PROPR_API_ORIGIN_PARITY_CASES } from '@propr/shared'; import { deepLinkFromArguments, applyDevelopmentRendererCsp, + applyPackagedRendererCsp, connectApiBaseUrlFromDeepLink, dashboardPathFromDeepLink, isSafeExternalUrl, @@ -12,6 +13,7 @@ import { normalizeDesktopDashboardPath, normalizeDeepLink, rendererContentSecurityPolicy, + rendererCspAllowsConnectUrl, validatedDevServerUrl, } from './security'; @@ -245,7 +247,62 @@ describe('desktop URL security', () => { assert.match(policy, /frame-src 'none'/); assert.doesNotMatch(policy, /unsafe-eval/); assert.match(policy, /script-src 'self'(?:;|$)/); - assert.match(policy, /connect-src 'self' https: http: ws: wss:/); + assert.match(policy, /connect-src 'self' https: wss:/); + assert.doesNotMatch(policy, /(?:^|\s)http:(?:\s|;|$)/); + assert.doesNotMatch(policy, /(?:^|\s)ws:(?:\s|;|$)/); + assert.match(policy, /http:\/\/localhost:\*/); + assert.match(policy, /http:\/\/\*\.localhost:\*/); + assert.match(policy, /http:\/\/127\.0\.0\.1:\*/); + assert.match(policy, /http:\/\/\[::1\]:\*/); + }); + + it('limits cleartext renderer connections to canonical loopback policy sources', () => { + const configured = ['http://127.42.7.9:43127']; + for (const url of [ + 'http://localhost:4000/api/tasks', + 'ws://localhost:4000/socket.io/', + 'http://api.dev.localhost:5173/api/tasks', + 'ws://api.dev.localhost:5173/socket.io/', + 'http://127.0.0.1:65535/api/tasks', + 'ws://127.0.0.1:65535/socket.io/', + 'http://[::1]:4000/api/tasks', + 'ws://[::1]:4000/socket.io/', + 'http://127.42.7.9:43127/api/tasks', + 'ws://127.42.7.9:43127/socket.io/', + 'https://remote.example.test/api/tasks', + 'wss://t-instance123.propr.dev/socket.io/', + ]) assert.equal(rendererCspAllowsConnectUrl(url, configured), true, url); + + for (const url of [ + 'http://192.168.1.20:4000/api/tasks', + 'ws://10.0.0.8:4000/socket.io/', + 'http://example.test/api/tasks', + 'ws://example.test/socket.io/', + 'http://localhost.example.test:4000/api/tasks', + 'http://localhost.:4000/api/tasks', + 'ws://api.dev.localhost.:5173/socket.io/', + 'ws://127.42.7.8:43127/socket.io/', + 'http://127.1:4000/api/tasks', + 'http://0177.0.0.1:4000/api/tasks', + 'http://0x7f000001:4000/api/tasks', + 'http://[::ffff:127.0.0.1]:4000/api/tasks', + 'http://local%68ost:4000/api/tasks', + ]) assert.equal(rendererCspAllowsConnectUrl(url, configured), false, url); + }); + + it('injects exact non-default 127/8 HTTP and WebSocket sources into packaged HTML', () => { + const baseline = rendererContentSecurityPolicy(); + const html = ``; + const transformed = applyPackagedRendererCsp(html, [ + 'http://127.42.7.9:43127', + 'http://192.168.1.20:4000', + 'https://remote.example.test', + ]); + + assert.match(transformed, /http:\/\/127\.42\.7\.9:43127/); + assert.match(transformed, /ws:\/\/127\.42\.7\.9:43127/); + assert.doesNotMatch(transformed, /192\.168\.1\.20/); + assert.doesNotMatch(transformed, /remote\.example\.test/); }); it('relaxes inline scripts only while Vite serves the development renderer', () => { diff --git a/apps/desktop/src/security.ts b/apps/desktop/src/security.ts index 7d4931734..0d987ddfc 100644 --- a/apps/desktop/src/security.ts +++ b/apps/desktop/src/security.ts @@ -193,15 +193,83 @@ export const deepLinkFromArguments = (argv: readonly string[]): string | null => return null; }; -export const rendererContentSecurityPolicy = (development = false): string => [ +const STATIC_RENDERER_CLEARTEXT_CONNECT_SOURCES = [ + 'http://localhost:*', + 'http://*.localhost:*', + 'http://127.0.0.1:*', + 'http://[::1]:*', + 'ws://localhost:*', + 'ws://*.localhost:*', + 'ws://127.0.0.1:*', + 'ws://[::1]:*', +] as const; + +const staticallyAllowedRendererLoopbackHostname = (hostname: string): boolean => { + const normalized = hostname.toLowerCase(); + return normalized === 'localhost' + || normalized.endsWith('.localhost') + || normalized === '127.0.0.1' + || normalized === '[::1]'; +}; + +const rendererEndpointOrigins = (apiBaseUrls: readonly string[]): Set => { + const origins = new Set(); + for (const value of apiBaseUrls) { + const normalized = normalizeApiBaseUrl(value); + if (!normalized) continue; + const url = new URL(normalized); + if (url.protocol === 'http:' && isProprLoopbackHostname(url.hostname)) origins.add(normalized); + } + return origins; +}; + +const rendererProfileConnectSources = (apiBaseUrls: readonly string[]): string[] => { + const sources: string[] = []; + for (const origin of rendererEndpointOrigins(apiBaseUrls)) { + const url = new URL(origin); + if (staticallyAllowedRendererLoopbackHostname(url.hostname)) continue; + sources.push(origin, `ws://${url.host}`); + } + return sources.sort(); +}; + +/** Match the generated connect-src boundary without relying on URL canonicalization aliases. */ +export const rendererCspAllowsConnectUrl = ( + value: string, + apiBaseUrls: readonly string[] = [], +): boolean => { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return false; + } + if (parsed.username || parsed.password) return false; + if (parsed.protocol === 'https:' || parsed.protocol === 'wss:') return true; + if (parsed.protocol !== 'http:' && parsed.protocol !== 'ws:') return false; + + const httpCandidate = parsed.protocol === 'ws:' ? `http:${value.slice('ws:'.length)}` : value; + const canonicalOrigin = canonicalProprHttpUrlOrigin(httpCandidate); + if (!canonicalOrigin) return false; + const canonicalUrl = new URL(canonicalOrigin); + if (staticallyAllowedRendererLoopbackHostname(canonicalUrl.hostname)) return true; + return rendererEndpointOrigins(apiBaseUrls).has(canonicalOrigin); +}; + +export const rendererContentSecurityPolicy = ( + development = false, + apiBaseUrls: readonly string[] = [], +): string => [ "default-src 'self'", `script-src 'self'${development ? " 'unsafe-inline'" : ''}`, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob: https:", "font-src 'self' data:", - // Electron main applies the shared canonical origin rule before any request; - // scheme sources are required here because CSP cannot express IPv4 127/8. - "connect-src 'self' https: http: ws: wss:", + [ + "connect-src 'self' https: wss:", + ...STATIC_RENDERER_CLEARTEXT_CONNECT_SOURCES, + ...rendererProfileConnectSources(apiBaseUrls), + ].join(' '), "object-src 'none'", "base-uri 'none'", "form-action 'none'", @@ -215,3 +283,15 @@ export const applyDevelopmentRendererCsp = (html: string): string => { } return html.replace(packagedPolicy, rendererContentSecurityPolicy(true)); }; + +/** Replace the build-time baseline with the policy prepared before packaged navigation. */ +export const applyPackagedRendererCsp = ( + html: string, + apiBaseUrls: readonly string[], +): string => { + const packagedPolicy = rendererContentSecurityPolicy(); + if (!html.includes(packagedPolicy)) { + throw new Error('renderer.html is missing the packaged content security policy'); + } + return html.replace(packagedPolicy, rendererContentSecurityPolicy(false, apiBaseUrls)); +}; diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index 2fe16b87e..9060e7ad8 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -93,6 +93,8 @@ export interface DesktopConnectionScope { export interface DesktopActivatedConnection extends DesktopConnectionScope { status: 'ready'; identityEpoch: string; + /** The main process committed a stricter replacement document policy and scheduled a reload. */ + rendererReloadRequired?: true; } export interface DesktopAccessInvalidation extends DesktopConnectionScope { diff --git a/propr-ui/src/desktop/DesktopExperience.transport.test.tsx b/propr-ui/src/desktop/DesktopExperience.transport.test.tsx index b183b1030..4894c7941 100644 --- a/propr-ui/src/desktop/DesktopExperience.transport.test.tsx +++ b/propr-ui/src/desktop/DesktopExperience.transport.test.tsx @@ -150,6 +150,29 @@ describe('DesktopExperience transport and fencing', () => { expect(apiMock.setApiBaseUrl).not.toHaveBeenCalled(); }); + it('does not publish renderer networking before a replacement document CSP reload', async () => { + const adapters = adaptersFor([localProfile], localProfile.id, async () => ({ + status: 'ready', version: '0.8.15', activationTicket: 'ticket-loopback', + })); + adapters.connection.activate = vi.fn(async () => ({ + status: 'ready' as const, + profileId: localProfile.id, + transportScope: 'scope-loopback', + identityEpoch: 'L'.repeat(22), + rendererReloadRequired: true as const, + })); + adapters.connection.publishActivation = vi.fn(); + + render(
Premature network app
); + + await waitFor(() => expect(adapters.connection.activate).toHaveBeenCalledOnce()); + expect(screen.getByRole('heading', { name: 'Connecting to This computer' })).toBeInTheDocument(); + expect(screen.queryByText('Premature network app')).not.toBeInTheDocument(); + expect(adapters.connection.publishActivation).not.toHaveBeenCalled(); + expect(runtimeMock.setDesktopApiBaseUrl).not.toHaveBeenCalled(); + expect(apiMock.setApiBaseUrl).not.toHaveBeenCalled(); + }); + it('ignores a stale connection result after the adapters change', async () => { let resolveFirstProbe: ((result: DesktopConnectionResult) => void) | undefined; const firstProbe = vi.fn(() => new Promise(resolve => { diff --git a/propr-ui/src/desktop/DesktopExperience.tsx b/propr-ui/src/desktop/DesktopExperience.tsx index 30e9c099e..f4596d815 100644 --- a/propr-ui/src/desktop/DesktopExperience.tsx +++ b/propr-ui/src/desktop/DesktopExperience.tsx @@ -115,6 +115,9 @@ export const DesktopExperience: React.FC = ({ adapters, } }); if (!isCurrentAttempt()) return; + // Main has already committed the active profile and schedules a reload. + // Do not publish an endpoint until the replacement document CSP is active. + if (result.status === 'ready' && result.rendererReloadRequired) return; setProfiles(current => mergeProfiles(current, [connectedProfile])); if (result.status !== 'ready') { setState({ phase: 'blocked', profile: connectedProfile, result }); diff --git a/propr-ui/src/desktop/electronAdapters.test.ts b/propr-ui/src/desktop/electronAdapters.test.ts index 711cab66f..ab42fa5fa 100644 --- a/propr-ui/src/desktop/electronAdapters.test.ts +++ b/propr-ui/src/desktop/electronAdapters.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { PROPR_API_ORIGIN_PARITY_CASES } from '@propr/shared'; -import type { DesktopBridge, DesktopProfile as StoredProfile } from '../../../apps/desktop/src/shared/contract'; +import type { + DesktopActivatedConnection, + DesktopBridge, + DesktopProfile as StoredProfile, +} from '../../../apps/desktop/src/shared/contract'; import { createElectronDesktopAdapters } from './electronAdapters'; const desktopConnectionState = vi.hoisted(() => ({ @@ -32,7 +36,7 @@ const bridgeFixture = () => { version: '0.8.15', activationTicket: 'ticket-7', })); - const activate = vi.fn(async () => ({ + const activate = vi.fn(async (): Promise => ({ status: 'ready' as const, profileId: storedProfile.id, transportScope: 'scope-7', @@ -219,6 +223,30 @@ describe('Electron remote instance adapters', () => { }); }); + it('preserves the main-process replacement-policy reload marker', async () => { + const fixture = bridgeFixture(); + fixture.activate.mockResolvedValueOnce({ + status: 'ready', + profileId: storedProfile.id, + transportScope: 'scope-reload', + identityEpoch: 'R'.repeat(22), + rendererReloadRequired: true, + }); + const adapters = createElectronDesktopAdapters(fixture.bridge); + const profile = (await adapters.profiles.list())[0]; + + const activated = await adapters.connection.activate?.(profile, { + status: 'ready', activationTicket: 'ticket-reload', + }); + + expect(activated).toEqual(expect.objectContaining({ + status: 'ready', + transportScope: 'scope-reload', + rendererReloadRequired: true, + })); + expect(setDesktopConnectionScope).not.toHaveBeenCalled(); + }); + it('clears renderer storage after a successful same-origin profile switch', async () => { const fixture = bridgeFixture(); await fixture.bridge.profiles.save({ diff --git a/propr-ui/src/desktop/electronAdapters.ts b/propr-ui/src/desktop/electronAdapters.ts index f98cc53ea..0812ba6d6 100644 --- a/propr-ui/src/desktop/electronAdapters.ts +++ b/propr-ui/src/desktop/electronAdapters.ts @@ -201,6 +201,7 @@ export const createElectronDesktopAdapters = (bridge: DesktopBridge): DesktopAda profileId: activated.profileId, transportScope: activated.transportScope, identityEpoch: activated.identityEpoch, + ...(activated.rendererReloadRequired ? { rendererReloadRequired: true as const } : {}), }; }, publishActivation(profile, result) { diff --git a/propr-ui/src/desktop/types.ts b/propr-ui/src/desktop/types.ts index c6b3f3cbe..4d1a47a34 100644 --- a/propr-ui/src/desktop/types.ts +++ b/propr-ui/src/desktop/types.ts @@ -9,7 +9,7 @@ export interface DesktopProfile { } export type DesktopConnectionResult = - | { status: 'ready'; version?: string; authentication?: string; activationTicket?: string; transportScope?: string; profileId?: string; identityEpoch?: string } + | { status: 'ready'; version?: string; authentication?: string; activationTicket?: string; transportScope?: string; profileId?: string; identityEpoch?: string; rendererReloadRequired?: true } | { status: 'authentication-required'; message?: string; version?: string; authentication?: string } | { status: 'incompatible'; message: string; version?: string } | { status: 'offline'; message: string };