Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/renderer.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: http: ws: wss:; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: wss: http://localhost:* http://*.localhost:* http://127.0.0.1:* http://[::1]:* ws://localhost:* ws://*.localhost:* ws://127.0.0.1:* ws://[::1]:*; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f8fafc" />
Expand Down
57 changes: 57 additions & 0 deletions apps/desktop/src/ipc-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (...args: any[]) => 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<string, (...args: any[]) => unknown>();
const ipcMain = {
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ interface RegisterIpcOptions {
devServerUrl: string | undefined;
packagedRendererUrl: string;
openExternal(url: string): Promise<void>;
/** 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. */
Expand Down Expand Up @@ -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({
Expand Down
45 changes: 42 additions & 3 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -156,6 +158,23 @@ interface PackagedTransportSmoke {
}
let activePackagedTransportSmoke: PackagedTransportSmoke | null = null;
let activePackagedConnectJourney = false;
const rendererCspApiBaseUrls = new Set<string>();
let loadedRendererCspApiBaseUrls = new Set<string>();

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;
Expand Down Expand Up @@ -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 });
Expand All @@ -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); };
};
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down
59 changes: 58 additions & 1 deletion apps/desktop/src/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PROPR_API_ORIGIN_PARITY_CASES } from '@propr/shared';
import {
deepLinkFromArguments,
applyDevelopmentRendererCsp,
applyPackagedRendererCsp,
connectApiBaseUrlFromDeepLink,
dashboardPathFromDeepLink,
isSafeExternalUrl,
Expand All @@ -12,6 +13,7 @@ import {
normalizeDesktopDashboardPath,
normalizeDeepLink,
rendererContentSecurityPolicy,
rendererCspAllowsConnectUrl,
validatedDevServerUrl,
} from './security';

Expand Down Expand Up @@ -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 = `<meta http-equiv="Content-Security-Policy" content="${baseline}">`;
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', () => {
Expand Down
88 changes: 84 additions & 4 deletions apps/desktop/src/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
const origins = new Set<string>();
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'",
Expand All @@ -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));
};
2 changes: 2 additions & 0 deletions apps/desktop/src/shared/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading