Skip to content

Commit 8efd7e3

Browse files
authored
fix(desktop): harden workspace Markdown navigation
Harden the Electron main-window navigation boundary and route workspace Markdown links through safe external-open handling.
1 parent 8ea4bff commit 8efd7e3

7 files changed

Lines changed: 219 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@open-codesign/desktop": patch
3+
---
4+
5+
Harden desktop navigation so workspace Markdown links cannot replace the app window.

apps/desktop/src/main/index.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { mkdirSync } from 'node:fs';
22
import { dirname, join } from 'node:path';
3-
import { fileURLToPath } from 'node:url';
3+
import { fileURLToPath, pathToFileURL } from 'node:url';
44
import { BRAND } from '@open-codesign/shared';
55
import type { BrowserWindow as ElectronBrowserWindow } from 'electron';
66
import { autoUpdater } from 'electron-updater';
@@ -21,6 +21,7 @@ import { getPendingUpdate, setupAutoUpdater } from './ipc/update';
2121
import { registerLocaleIpc } from './locale-ipc';
2222
import { getLogger, initLogger } from './logger';
2323
import { registerMemoryIpc } from './memory-ipc';
24+
import { isTrustedMainWindowNavigationUrl } from './navigation-policy';
2425
import { loadConfigOnBoot, registerOnboardingIpc } from './onboarding-ipc';
2526
import { isAllowedExternalUrl } from './open-external';
2627
import { readPersisted as readPreferences, registerPreferencesIpc } from './preferences-ipc';
@@ -63,7 +64,23 @@ if (storageLocations.dataDir !== undefined) {
6364
app.setPath('userData', storageLocations.dataDir);
6465
}
6566

67+
type NavigationEvent = { preventDefault: () => void };
68+
69+
function handleMainWindowNavigation(
70+
event: NavigationEvent,
71+
url: string,
72+
trustedAppUrl: string,
73+
): void {
74+
if (isTrustedMainWindowNavigationUrl(url, trustedAppUrl)) return;
75+
76+
event.preventDefault();
77+
}
78+
6679
function createWindow(): void {
80+
const rendererEntryPath = join(__dirname, '../renderer/index.html');
81+
const rendererUrlOverride = process.env['ELECTRON_RENDERER_URL'];
82+
const rendererEntryUrl = rendererUrlOverride || pathToFileURL(rendererEntryPath).href;
83+
6784
mainWindow = new BrowserWindow({
6885
width: 1280,
6986
height: 820,
@@ -101,6 +118,17 @@ function createWindow(): void {
101118
return { action: 'deny' };
102119
});
103120

121+
mainWindow.webContents.on('will-navigate', (event: NavigationEvent, url: string) => {
122+
handleMainWindowNavigation(event, url, rendererEntryUrl);
123+
});
124+
125+
mainWindow.webContents.on(
126+
'will-redirect',
127+
(event: NavigationEvent, url: string, _isInPlace: boolean, isMainFrame: boolean) => {
128+
if (isMainFrame) handleMainWindowNavigation(event, url, rendererEntryUrl);
129+
},
130+
);
131+
104132
// Replay any update event that fired before this window was ready
105133
// (macOS: user closed window, triggered a manual Check for Updates from
106134
// the app menu, then reopened — the event would otherwise be lost).
@@ -111,10 +139,10 @@ function createWindow(): void {
111139
}
112140
});
113141

114-
if (process.env['ELECTRON_RENDERER_URL']) {
115-
void mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
142+
if (rendererUrlOverride) {
143+
void mainWindow.loadURL(rendererEntryUrl);
116144
} else {
117-
void mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
145+
void mainWindow.loadFile(rendererEntryPath);
118146
}
119147
}
120148

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { pathToFileURL } from 'node:url';
2+
import { describe, expect, it } from 'vitest';
3+
import { isTrustedMainWindowNavigationUrl } from './navigation-policy';
4+
5+
describe('isTrustedMainWindowNavigationUrl', () => {
6+
it('allows same-origin dev-server navigation', () => {
7+
expect(
8+
isTrustedMainWindowNavigationUrl(
9+
'http://localhost:5173/dashboard?tab=files',
10+
'http://localhost:5173/',
11+
),
12+
).toBe(true);
13+
});
14+
15+
it('rejects remote navigation away from the dev app origin', () => {
16+
expect(isTrustedMainWindowNavigationUrl('https://example.com', 'http://localhost:5173/')).toBe(
17+
false,
18+
);
19+
});
20+
21+
it('rejects same-host navigation on a different port', () => {
22+
expect(
23+
isTrustedMainWindowNavigationUrl('http://localhost:3000/', 'http://localhost:5173/'),
24+
).toBe(false);
25+
});
26+
27+
it('allows hash navigation on the packaged renderer file', () => {
28+
const trusted = pathToFileURL('/Applications/Open CoDesign.app/Contents/renderer/index.html');
29+
const target = new URL('#workspace', trusted);
30+
31+
expect(isTrustedMainWindowNavigationUrl(target.href, trusted.href)).toBe(true);
32+
});
33+
34+
it('rejects other file URLs when the packaged renderer is trusted', () => {
35+
const trusted = pathToFileURL('/Applications/Open CoDesign.app/Contents/renderer/index.html');
36+
const target = pathToFileURL('/Users/user/Documents/notes.md');
37+
38+
expect(isTrustedMainWindowNavigationUrl(target.href, trusted.href)).toBe(false);
39+
});
40+
41+
it('rejects malformed URLs', () => {
42+
expect(isTrustedMainWindowNavigationUrl('not a url', 'http://localhost:5173/')).toBe(false);
43+
expect(isTrustedMainWindowNavigationUrl('https://example.com', 'not a url')).toBe(false);
44+
});
45+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { fileURLToPath } from 'node:url';
2+
3+
function parseUrl(raw: string): URL | null {
4+
try {
5+
return new URL(raw);
6+
} catch {
7+
return null;
8+
}
9+
}
10+
11+
function sameHttpOrigin(a: URL, b: URL): boolean {
12+
return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port;
13+
}
14+
15+
function sameFilePath(a: URL, b: URL): boolean {
16+
try {
17+
return fileURLToPath(a) === fileURLToPath(b);
18+
} catch {
19+
return false;
20+
}
21+
}
22+
23+
export function isTrustedMainWindowNavigationUrl(rawUrl: string, trustedAppUrl: string): boolean {
24+
const target = parseUrl(rawUrl);
25+
const trusted = parseUrl(trustedAppUrl);
26+
if (target === null || trusted === null) return false;
27+
28+
if (trusted.protocol === 'http:' || trusted.protocol === 'https:') {
29+
return sameHttpOrigin(target, trusted);
30+
}
31+
32+
if (trusted.protocol === 'file:') {
33+
return target.protocol === 'file:' && sameFilePath(target, trusted);
34+
}
35+
36+
return false;
37+
}

apps/desktop/src/renderer/src/components/FilesTabView.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
RefreshCw,
1818
} from 'lucide-react';
1919
import {
20+
type AnchorHTMLAttributes,
2021
type ChangeEvent,
2122
type KeyboardEvent,
2223
lazy,
@@ -40,6 +41,7 @@ import {
4041
useLazyDesignFileTree,
4142
} from '../hooks/useDesignFiles';
4243
import type { FileTreeNode } from '../lib/file-tree';
44+
import { classifyMarkdownHref } from '../lib/markdown-links';
4345
import { workspacePathComparisonKey } from '../lib/workspace-path';
4446
import {
4547
formatIframeError,
@@ -223,6 +225,38 @@ function escapeHtmlText(value: string): string {
223225
.replace(/'/g, ''');
224226
}
225227

228+
function MarkdownLink({
229+
href,
230+
children,
231+
node: _node,
232+
...props
233+
}: AnchorHTMLAttributes<HTMLAnchorElement> & { node?: unknown }) {
234+
const action = classifyMarkdownHref(href);
235+
236+
if (action.kind === 'anchor') {
237+
return (
238+
<a {...props} href={action.href}>
239+
{children}
240+
</a>
241+
);
242+
}
243+
244+
if (action.kind === 'external') {
245+
const handleClick = (event: ReactMouseEvent<HTMLAnchorElement>) => {
246+
event.preventDefault();
247+
void window.codesign?.openExternal?.(action.url).catch(() => undefined);
248+
};
249+
250+
return (
251+
<a {...props} href={action.url} rel={props.rel ?? 'noreferrer'} onClick={handleClick}>
252+
{children}
253+
</a>
254+
);
255+
}
256+
257+
return <span>{children}</span>;
258+
}
259+
226260
function WorkspaceSection({ files }: { files: DesignFileEntry[] }) {
227261
const t = useT();
228262
const currentDesignId = useCodesignStore((s) => s.currentDesignId);
@@ -1060,7 +1094,9 @@ function TextFilePreview({
10601094
</pre>
10611095
</details>
10621096
) : null}
1063-
<ReactMarkdown remarkPlugins={[remarkGfm]}>{markdown.body}</ReactMarkdown>
1097+
<ReactMarkdown components={{ a: MarkdownLink }} remarkPlugins={[remarkGfm]}>
1098+
{markdown.body}
1099+
</ReactMarkdown>
10641100
</article>
10651101
) : (
10661102
<pre
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { classifyMarkdownHref } from './markdown-links';
3+
4+
describe('classifyMarkdownHref', () => {
5+
it('keeps in-document anchors clickable', () => {
6+
expect(classifyMarkdownHref('#details')).toEqual({ kind: 'anchor', href: '#details' });
7+
});
8+
9+
it('routes https URLs through the safe external-open path', () => {
10+
expect(
11+
classifyMarkdownHref('https://github.com/OpenCoworkAI/open-codesign/issues/339'),
12+
).toEqual({
13+
kind: 'external',
14+
url: 'https://github.com/OpenCoworkAI/open-codesign/issues/339',
15+
});
16+
});
17+
18+
it('normalizes surrounding whitespace on external URLs', () => {
19+
expect(
20+
classifyMarkdownHref(' https://github.com/OpenCoworkAI/open-codesign/releases '),
21+
).toEqual({
22+
kind: 'external',
23+
url: 'https://github.com/OpenCoworkAI/open-codesign/releases',
24+
});
25+
});
26+
27+
it('blocks relative links that would otherwise navigate the app document', () => {
28+
expect(classifyMarkdownHref('./other.md')).toEqual({ kind: 'blocked' });
29+
expect(classifyMarkdownHref('/absolute/path')).toEqual({ kind: 'blocked' });
30+
});
31+
32+
it('blocks unsafe protocols', () => {
33+
expect(classifyMarkdownHref('javascript:alert(1)')).toEqual({ kind: 'blocked' });
34+
expect(classifyMarkdownHref('file:///Users/user/.ssh/id_rsa')).toEqual({ kind: 'blocked' });
35+
expect(classifyMarkdownHref('http://example.com')).toEqual({ kind: 'blocked' });
36+
});
37+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export type MarkdownHrefAction =
2+
| { kind: 'anchor'; href: string }
3+
| { kind: 'external'; url: string }
4+
| { kind: 'blocked' };
5+
6+
export function classifyMarkdownHref(rawHref: string | undefined): MarkdownHrefAction {
7+
const href = rawHref?.trim();
8+
if (!href) return { kind: 'blocked' };
9+
10+
if (href.startsWith('#')) {
11+
return { kind: 'anchor', href };
12+
}
13+
14+
let parsed: URL;
15+
try {
16+
parsed = new URL(href);
17+
} catch {
18+
return { kind: 'blocked' };
19+
}
20+
21+
if (parsed.protocol !== 'https:') {
22+
return { kind: 'blocked' };
23+
}
24+
25+
return { kind: 'external', url: parsed.href };
26+
}

0 commit comments

Comments
 (0)