Skip to content
Open
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
5 changes: 3 additions & 2 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ generated workspace `dist` directories.
Development renderer URLs are accepted only when Electron Forge supplies an HTTP loopback URL. Packaged builds load
the generated renderer from the application ASAR through an app-owned protocol.

The packaged-binary smoke test verifies the hardened fuse states, launches the Linux artifact without a
The packaged-binary smoke test verifies the hardened fuse states, launches the Linux artifact at 1280x820 without a
sandbox-disabling flag, rejects main-process uncaught exceptions, and requires proof that `window.proprDesktop` is
exposed before accepting renderer-ready and a clean exit.
exposed. It also checks the real renderer bounds for the title-bar logo and connection-card controls before accepting
renderer-ready and a clean exit.

`desktop:audit` deliberately applies separate policies to the two dependency surfaces: low-or-higher advisories fail
the production-runtime audit, while high and critical advisories fail the desktop development/build-tool audit. Release
Expand Down
60 changes: 59 additions & 1 deletion apps/desktop/scripts/smoke-packaged.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
const READY_EVENT = 'desktop.renderer.ready';
const PRELOAD_BRIDGE_PROOF = '"preloadBridgeExposed":true';
const PROFILE_API_PROOF = 'desktop.renderer.profile_api.ready';
const LAYOUT_READY_EVENT = 'desktop.renderer.layout.ready';
const MAIN_PROCESS_ERROR_MARKERS = [
'desktop.main_process.uncaught_exception',
'A JavaScript error occurred in the main process',
Expand All @@ -23,6 +24,62 @@ const MAIN_PROCESS_ERROR_MARKERS = [
const TIMEOUT_MS = 30_000;
const binaryPath = resolve('out', `propr-desktop-linux-${process.arch}`, 'propr-desktop');

const parseLayout = smokeOutput => {
for (const line of smokeOutput.split(/\r?\n/)) {
if (!line.includes(LAYOUT_READY_EVENT)) continue;
try {
const record = JSON.parse(line.slice(line.indexOf('{')));
if (record.event === LAYOUT_READY_EVENT) return record.layout;
} catch {
// Ignore non-JSON Chromium output that happens to mention the event name.
}
}
return undefined;
};

const assertGap = (before, after, minimum, description) => {
const gap = after.top - before.bottom;
if (gap < minimum) {
throw new Error(`Packaged layout ${description} gap was ${gap}px; expected at least ${minimum}px`);
}
};

const assertPackagedLayout = layout => {
if (!layout) throw new Error('Packaged desktop did not report renderer layout bounds');
if (layout.missing?.length) {
throw new Error(`Packaged renderer layout was missing: ${layout.missing.join(', ')}`);
}
if (layout.windowBounds?.width !== 1280 || layout.windowBounds?.height !== 820) {
throw new Error(`Packaged window was not 1280x820: ${JSON.stringify(layout.windowBounds)}`);
}
if (layout.viewport.width < 1200 || layout.viewport.height < 740) {
throw new Error(`Packaged renderer viewport is unexpectedly small: ${JSON.stringify(layout.viewport)}`);
}
if (layout.logo.height < 18 || layout.logo.height > 22 || layout.logo.width < 40 || layout.logo.width > 100) {
throw new Error(`Packaged title-bar logo has unreasonable bounds: ${JSON.stringify(layout.logo)}`);
}
if (
layout.logo.top < layout.titlebar.top
|| layout.logo.bottom > layout.titlebar.bottom
|| layout.card.left < 0
|| layout.card.right > layout.viewport.width
|| layout.card.top < layout.titlebar.bottom
|| layout.card.bottom > layout.viewport.height
) {
throw new Error('Packaged logo or connection card extends outside its layout container');
}
for (const name of ['connectionName', 'apiUrl', 'submit']) {
const control = layout[name];
if (control.height < 36 || control.left < layout.card.left || control.right > layout.card.right) {
throw new Error(`Packaged ${name} control has unreasonable bounds: ${JSON.stringify(control)}`);
}
}
assertGap(layout.connectionName, layout.apiUrl, 28, 'between connection inputs');
assertGap(layout.apiUrl, layout.apiHelp, 6, 'between API input and help text');
assertGap(layout.apiHelp, layout.submit, 16, 'between API help and submit button');
assertGap(layout.submit, layout.footer, 20, 'between submit button and runtime footer');
};

if (process.platform !== 'linux') {
throw new Error('The packaged-binary smoke test currently targets the Linux artifact');
}
Expand Down Expand Up @@ -137,8 +194,9 @@ try {
if (!output.includes(PROFILE_API_PROOF) || receivedProfileApiOrigin !== DESKTOP_RENDERER_ORIGIN) {
throw new Error('Packaged desktop did not complete a profile API request from its exact renderer origin');
}
assertPackagedLayout(parseLayout(output));

console.log('Packaged Linux desktop reached renderer-ready and completed a profile API request with sandboxing enabled.');
console.log('Packaged Linux desktop reached renderer-ready with compiled layout, sandboxing, and profile API proof.');
} finally {
profileApiServer.closeAllConnections();
await new Promise(resolveClose => profileApiServer.close(resolveClose));
Expand Down
48 changes: 48 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const devServerUrl = typeof MAIN_WINDOW_VITE_DEV_SERVER_URL === 'string'
: undefined;
const PACKAGED_RENDERER_SCHEME = 'propr-app';
const PACKAGED_RENDERER_HOST = 'renderer';
const PACKAGED_LAYOUT_READY_EVENT = 'desktop.renderer.layout.ready';
const packagedRendererRoot = join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}`);
const packagedRendererUrl = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`;
let mainWindow: BrowserWindow | null = null;
Expand Down Expand Up @@ -110,6 +111,50 @@ const openAllowedExternalUrl = async (url: string): Promise<void> => {
await shell.openExternal(url);
};

const inspectPackagedLayout = async (window: BrowserWindow): Promise<Record<string, unknown>> => {
const rendererLayout = await window.webContents.executeJavaScript(`(async () => {
const deadline = performance.now() + 5000;
let elements;
do {
const card = document.querySelector('.desktop-connection-card');
const form = card?.querySelector('form');
const labels = form ? Array.from(form.querySelectorAll(':scope > label')) : [];
elements = {
titlebar: document.querySelector('.desktop-titlebar'),
logo: document.querySelector('.desktop-titlebar img[alt="ProPR"]'),
card,
connectionName: labels[0]?.querySelector('input'),
apiUrl: labels[1]?.querySelector('input'),
apiHelp: labels[1]?.querySelector('span'),
submit: form?.querySelector(':scope > button[type="submit"]'),
footer: card?.lastElementChild,
};
if (Object.values(elements).every(Boolean) && elements.footer.textContent.includes('Runtime:')) break;
await new Promise(resolve => setTimeout(resolve, 25));
} while (performance.now() < deadline);

const missing = Object.entries(elements).filter(([, element]) => !element).map(([name]) => name);
if (missing.length > 0) return { missing };
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const bounds = element => {
const rect = element.getBoundingClientRect();
return {
bottom: rect.bottom,
height: rect.height,
left: rect.left,
right: rect.right,
top: rect.top,
width: rect.width,
};
};
return {
viewport: { height: window.innerHeight, width: window.innerWidth },
...Object.fromEntries(Object.entries(elements).map(([name, element]) => [name, bounds(element)])),
};
})()`);
return { windowBounds: window.getBounds(), ...rendererLayout };
};

const createMainWindow = async (): Promise<BrowserWindow> => {
const window = new BrowserWindow(createBrowserWindowOptions(join(__dirname, 'preload.cjs'), !app.isPackaged));
const readyToShow = new Promise<void>(resolveReady => window.once('ready-to-show', resolveReady));
Expand Down Expand Up @@ -168,6 +213,9 @@ const createMainWindow = async (): Promise<BrowserWindow> => {
}
log('info', 'desktop.renderer.profile_api.ready', { origin: DESKTOP_RENDERER_ORIGIN });
}
if (app.isPackaged && process.env.PROPR_DESKTOP_SMOKE_TEST === '1') {
log('info', PACKAGED_LAYOUT_READY_EVENT, { layout: await inspectPackagedLayout(window) });
}
log('info', 'desktop.renderer.ready', { preloadBridgeExposed: true });
if (app.isPackaged && process.env.PROPR_DESKTOP_SMOKE_TEST === '1') {
app.quit();
Expand Down
70 changes: 70 additions & 0 deletions apps/desktop/src/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { describe, it } from 'node:test';
import {
deepLinkFromArguments,
applyDevelopmentRendererCsp,
dashboardPathFromDeepLink,
isSafeExternalUrl,
isTrustedRendererUrl,
normalizeApiBaseUrl,
normalizeDesktopDashboardPath,
normalizeDeepLink,
rendererContentSecurityPolicy,
validatedDevServerUrl,
Expand Down Expand Up @@ -73,6 +75,74 @@ describe('desktop URL security', () => {
assert.equal(normalizeDeepLink('propr://user:secret@connect'), null);
});

it('accepts a normal internal dashboard route from an open deep link', () => {
const link = 'propr://open?path=%2Ftasks';
const queryAndHashLink = 'propr://open?path=%2Ftasks%3Fstatus%3Dopen%23recent';
assert.equal(dashboardPathFromDeepLink(link), '/tasks');
assert.equal(normalizeDeepLink(link), link);
assert.equal(normalizeDesktopDashboardPath('/tasks?status=open'), '/tasks?status=open');
assert.equal(dashboardPathFromDeepLink(queryAndHashLink), '/tasks?status=open#recent');
assert.equal(normalizeDesktopDashboardPath('/tasks?status=open#recent'), '/tasks?status=open#recent');
});

it('revalidates open links after canonical serialization', () => {
const rawPath = `/tasks/${'é '.repeat(300)}end`;
const rawLink = `propr://open?path=${rawPath}`;
const expandedCanonicalLink = new URL(rawLink).href;
assert.ok(rawLink.length < 2_048);
assert.ok(expandedCanonicalLink.length > 2_048);
assert.notEqual(dashboardPathFromDeepLink(rawLink), null);
assert.equal(dashboardPathFromDeepLink(expandedCanonicalLink), null);
assert.equal(normalizeDeepLink(rawLink), null);

const canonicalPrefix = 'propr://open?path=%2Ftasks%2F';
const suffix = 'a'.repeat(2_048 - canonicalPrefix.length);
const boundaryCanonicalLink = `${canonicalPrefix}${suffix}`;
assert.equal(boundaryCanonicalLink.length, 2_048);
assert.equal(new URL(boundaryCanonicalLink).href, boundaryCanonicalLink);
assert.equal(dashboardPathFromDeepLink(boundaryCanonicalLink), `/tasks/${suffix}`);
assert.equal(normalizeDeepLink(boundaryCanonicalLink), boundaryCanonicalLink);
});

it('rejects encoded delimiters combined with encoded traversal', () => {
const rejectedPaths = [
'/tasks%23/%2e%2e/login',
'/tasks%23/%252e%252e/login',
'/tasks%3f/%2e%2e/login',
'/tasks%3f/%252e%252e/login',
];

rejectedPaths.forEach(path => {
const link = `propr://open?path=${encodeURIComponent(path)}`;
assert.equal(normalizeDesktopDashboardPath(path), null, path);
assert.equal(dashboardPathFromDeepLink(link), null, link);
assert.equal(normalizeDeepLink(link), null, link);
});
});

it('rejects malformed and unsafe open deep-link paths', () => {
const rejected = [
'propr://open',
'propr://open?path=',
'propr://open?path=%2Ftasks&path=%2Fplans',
'propr://open?path=%2Ftasks&extra=true',
'propr://open?path=https%3A%2F%2Fevil.example%2Ftasks',
'propr://open?path=%2F%2Fevil.example%2Ftasks',
'propr://open?path=%2Ftasks%252F..%252Flogin',
'propr://open?path=%2Ftasks%252F%252e%252e%252Flogin',
'propr://open?path=%2Ftasks%250Anext',
'propr://open?path=%2Ftasks%255Cnext',
'propr://open?path=%2Flogin%3Fredirect_to%3D%252Ftasks',
'propr://open?path=%2Fdesktop%2Fpairing%3Fpairing_id%3Dattacker',
'propr://open?path=%2Ftasks%3Ftunnel%3Dt-attacker.propr.dev',
'propr://open?path=%2Ftasks%3Fflow%3Dattacker',
];
rejected.forEach(link => {
assert.equal(dashboardPathFromDeepLink(link), null, link);
assert.equal(normalizeDeepLink(link), null, link);
});
});

it('publishes a restrictive production policy', () => {
const policy = rendererContentSecurityPolicy();
assert.match(policy, /default-src 'self'/);
Expand Down
92 changes: 90 additions & 2 deletions apps/desktop/src/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import { DESKTOP_PROTOCOL } from './shared/contract';
// WHATWG URL.hostname retains brackets around IPv6 literals.
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost']);
const DEEP_LINK_ACTIONS = new Set(['connect', 'open']);
const DESKTOP_DASHBOARD_ORIGIN = 'https://desktop.propr.invalid';
const RESERVED_DASHBOARD_PARAMETERS = new Set([
'flow',
'logged_out',
'oauth_complete',
'redirect_to',
'tunnel',
]);

const parseUrl = (value: string): URL | null => {
try {
Expand All @@ -14,6 +22,77 @@ const parseUrl = (value: string): URL | null => {

const hasCredentials = (url: URL): boolean => Boolean(url.username || url.password);

const isSafeDashboardPathForm = (value: string): boolean => {
if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) return false;
if (/[\u0000-\u001F\u007F\\]/.test(value)) return false;
const pathname = value.split(/[?#]/, 1)[0];
return !pathname.split('/').some(segment => segment === '.' || segment === '..');
};

const isSafeDecodedPathScope = (value: string): boolean => {
if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) return false;
if (/[\u0000-\u001F\u007F\\]/.test(value)) return false;
return !value.split('/').some(segment => segment === '.' || segment === '..');
};

const isAllowedDashboardUrl = (url: URL): boolean => {
if (url.origin !== DESKTOP_DASHBOARD_ORIGIN) return false;
const route = url.pathname.toLowerCase().replace(/\/+$/, '') || '/';
if (route === '/login' || route.startsWith('/login/') || route === '/desktop/pairing') return false;
return ![...url.searchParams.keys()].some(key => RESERVED_DASHBOARD_PARAMETERS.has(key.toLowerCase()));
};

const fullyDecodeDashboardPath = (value: string): URL | null => {
let decoded = value;
// Keep the original path scope while decoding so encoded delimiters cannot hide traversal in a later layer.
let decodedPathScope = value.split(/[?#]/, 1)[0];
for (let remaining = value.length + 1; remaining > 0; remaining -= 1) {
if (!isSafeDashboardPathForm(decoded) || !isSafeDecodedPathScope(decodedPathScope)) return null;
let url: URL;
try {
url = new URL(decoded, DESKTOP_DASHBOARD_ORIGIN);
} catch {
return null;
}
if (!isAllowedDashboardUrl(url)) return null;
if (!decoded.includes('%')) return url;
if (/%(?![\da-f]{2})/i.test(decoded)) return null;
try {
const next = decodeURIComponent(decoded);
if (next === decoded) return url;
decoded = next;
decodedPathScope = decodeURIComponent(decodedPathScope);
} catch {
return null;
}
}
return null;
};

export const normalizeDesktopDashboardPath = (value: string): string | null => {
if (!value || value.length > 2_048) return null;
const url = fullyDecodeDashboardPath(value);
if (!url) return null;
return `${url.pathname}${url.search}${url.hash}`;
};

export const dashboardPathFromDeepLink = (value: string): string | null => {
if (value.length > 2_048 || /[\u0000-\u001F\u007F]/.test(value)) return null;
const url = parseUrl(value);
if (
!url
|| url.protocol !== `${DESKTOP_PROTOCOL}:`
|| url.hostname !== 'open'
|| hasCredentials(url)
|| url.port
|| url.hash
|| (url.pathname !== '' && url.pathname !== '/')
) return null;
const entries = [...url.searchParams.entries()];
if (entries.length !== 1 || entries[0][0] !== 'path') return null;
return normalizeDesktopDashboardPath(entries[0][1]);
};

export const normalizeApiBaseUrl = (value: string): string | null => {
const url = parseUrl(value.trim());
if (!url || hasCredentials(url) || url.hash || url.search) return null;
Expand Down Expand Up @@ -55,11 +134,20 @@ export const isTrustedRendererUrl = (
};

export const normalizeDeepLink = (value: string): string | null => {
if (value.length > 2_048) return null;
if (value.length > 2_048 || /[\u0000-\u001F\u007F]/.test(value)) return null;
const url = parseUrl(value);
if (!url || url.protocol !== `${DESKTOP_PROTOCOL}:` || hasCredentials(url)) return null;
if (!DEEP_LINK_ACTIONS.has(url.hostname) || url.port || url.hash) return null;
return url.href;
const dashboardPath = url.hostname === 'open' ? dashboardPathFromDeepLink(value) : null;
if (url.hostname === 'open' && dashboardPath === null) return null;

const canonicalCandidate = url.href;
if (canonicalCandidate.length > 2_048 || /[\u0000-\u001F\u007F]/.test(canonicalCandidate)) return null;
if (
url.hostname === 'open'
&& dashboardPathFromDeepLink(canonicalCandidate) !== dashboardPath
) return null;
return canonicalCandidate;
};

export const deepLinkFromArguments = (argv: readonly string[]): string | null => {
Expand Down
Loading
Loading