From 5a69e57d50e094aaf6bbff566b1d64a1cbfb6064 Mon Sep 17 00:00:00 2001 From: snowopsdev <6538071+snowopsdev@users.noreply.github.com> Date: Tue, 12 May 2026 11:41:15 -0400 Subject: [PATCH 1/2] fix: harden desktop navigation boundary --- apps/desktop/src/main/index.ts | 39 +++++++++++++-- .../src/main/navigation-policy.test.ts | 45 +++++++++++++++++ apps/desktop/src/main/navigation-policy.ts | 37 ++++++++++++++ .../renderer/src/components/FilesTabView.tsx | 48 ++++++++++++++++++- .../renderer/src/lib/markdown-links.test.ts | 37 ++++++++++++++ .../src/renderer/src/lib/markdown-links.ts | 26 ++++++++++ 6 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/main/navigation-policy.test.ts create mode 100644 apps/desktop/src/main/navigation-policy.ts create mode 100644 apps/desktop/src/renderer/src/lib/markdown-links.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/markdown-links.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 0b32f729..ce602ad9 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,6 +1,6 @@ import { mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { BRAND } from '@open-codesign/shared'; import type { BrowserWindow as ElectronBrowserWindow } from 'electron'; import { autoUpdater } from 'electron-updater'; @@ -21,6 +21,7 @@ import { getPendingUpdate, setupAutoUpdater } from './ipc/update'; import { registerLocaleIpc } from './locale-ipc'; import { getLogger, initLogger } from './logger'; import { registerMemoryIpc } from './memory-ipc'; +import { isTrustedMainWindowNavigationUrl } from './navigation-policy'; import { loadConfigOnBoot, registerOnboardingIpc } from './onboarding-ipc'; import { isAllowedExternalUrl } from './open-external'; import { readPersisted as readPreferences, registerPreferencesIpc } from './preferences-ipc'; @@ -63,7 +64,26 @@ if (storageLocations.dataDir !== undefined) { app.setPath('userData', storageLocations.dataDir); } +type NavigationEvent = { preventDefault: () => void }; + +function handleMainWindowNavigation( + event: NavigationEvent, + url: string, + trustedAppUrl: string, +): void { + if (isTrustedMainWindowNavigationUrl(url, trustedAppUrl)) return; + + event.preventDefault(); + if (isAllowedExternalUrl(url)) { + void shell.openExternal(url); + } +} + function createWindow(): void { + const rendererEntryPath = join(__dirname, '../renderer/index.html'); + const rendererUrlOverride = process.env['ELECTRON_RENDERER_URL']; + const rendererEntryUrl = rendererUrlOverride || pathToFileURL(rendererEntryPath).href; + mainWindow = new BrowserWindow({ width: 1280, height: 820, @@ -101,6 +121,17 @@ function createWindow(): void { return { action: 'deny' }; }); + mainWindow.webContents.on('will-navigate', (event: NavigationEvent, url: string) => { + handleMainWindowNavigation(event, url, rendererEntryUrl); + }); + + mainWindow.webContents.on( + 'will-redirect', + (event: NavigationEvent, url: string, _isInPlace: boolean, isMainFrame: boolean) => { + if (isMainFrame) handleMainWindowNavigation(event, url, rendererEntryUrl); + }, + ); + // Replay any update event that fired before this window was ready // (macOS: user closed window, triggered a manual Check for Updates from // the app menu, then reopened — the event would otherwise be lost). @@ -111,10 +142,10 @@ function createWindow(): void { } }); - if (process.env['ELECTRON_RENDERER_URL']) { - void mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']); + if (rendererUrlOverride) { + void mainWindow.loadURL(rendererEntryUrl); } else { - void mainWindow.loadFile(join(__dirname, '../renderer/index.html')); + void mainWindow.loadFile(rendererEntryPath); } } diff --git a/apps/desktop/src/main/navigation-policy.test.ts b/apps/desktop/src/main/navigation-policy.test.ts new file mode 100644 index 00000000..ce96c998 --- /dev/null +++ b/apps/desktop/src/main/navigation-policy.test.ts @@ -0,0 +1,45 @@ +import { pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { isTrustedMainWindowNavigationUrl } from './navigation-policy'; + +describe('isTrustedMainWindowNavigationUrl', () => { + it('allows same-origin dev-server navigation', () => { + expect( + isTrustedMainWindowNavigationUrl( + 'http://localhost:5173/dashboard?tab=files', + 'http://localhost:5173/', + ), + ).toBe(true); + }); + + it('rejects remote navigation away from the dev app origin', () => { + expect(isTrustedMainWindowNavigationUrl('https://example.com', 'http://localhost:5173/')).toBe( + false, + ); + }); + + it('rejects same-host navigation on a different port', () => { + expect( + isTrustedMainWindowNavigationUrl('http://localhost:3000/', 'http://localhost:5173/'), + ).toBe(false); + }); + + it('allows hash navigation on the packaged renderer file', () => { + const trusted = pathToFileURL('/Applications/Open CoDesign.app/Contents/renderer/index.html'); + const target = new URL('#workspace', trusted); + + expect(isTrustedMainWindowNavigationUrl(target.href, trusted.href)).toBe(true); + }); + + it('rejects other file URLs when the packaged renderer is trusted', () => { + const trusted = pathToFileURL('/Applications/Open CoDesign.app/Contents/renderer/index.html'); + const target = pathToFileURL('/Users/user/Documents/notes.md'); + + expect(isTrustedMainWindowNavigationUrl(target.href, trusted.href)).toBe(false); + }); + + it('rejects malformed URLs', () => { + expect(isTrustedMainWindowNavigationUrl('not a url', 'http://localhost:5173/')).toBe(false); + expect(isTrustedMainWindowNavigationUrl('https://example.com', 'not a url')).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/navigation-policy.ts b/apps/desktop/src/main/navigation-policy.ts new file mode 100644 index 00000000..9ec0cde1 --- /dev/null +++ b/apps/desktop/src/main/navigation-policy.ts @@ -0,0 +1,37 @@ +import { fileURLToPath } from 'node:url'; + +function parseUrl(raw: string): URL | null { + try { + return new URL(raw); + } catch { + return null; + } +} + +function sameHttpOrigin(a: URL, b: URL): boolean { + return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port; +} + +function sameFilePath(a: URL, b: URL): boolean { + try { + return fileURLToPath(a) === fileURLToPath(b); + } catch { + return false; + } +} + +export function isTrustedMainWindowNavigationUrl(rawUrl: string, trustedAppUrl: string): boolean { + const target = parseUrl(rawUrl); + const trusted = parseUrl(trustedAppUrl); + if (target === null || trusted === null) return false; + + if (trusted.protocol === 'http:' || trusted.protocol === 'https:') { + return sameHttpOrigin(target, trusted); + } + + if (trusted.protocol === 'file:') { + return target.protocol === 'file:' && sameFilePath(target, trusted); + } + + return false; +} diff --git a/apps/desktop/src/renderer/src/components/FilesTabView.tsx b/apps/desktop/src/renderer/src/components/FilesTabView.tsx index a5e2b653..605e9b60 100644 --- a/apps/desktop/src/renderer/src/components/FilesTabView.tsx +++ b/apps/desktop/src/renderer/src/components/FilesTabView.tsx @@ -2,11 +2,21 @@ import { useT } from '@open-codesign/i18n'; import { buildPreviewDocument, isRenderablePath } from '@open-codesign/runtime'; import { DEFAULT_SOURCE_ENTRY, LEGACY_SOURCE_ENTRY } from '@open-codesign/shared'; import { FileCode2, FileText, Folder, FolderOpen } from 'lucide-react'; -import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import { + type AnchorHTMLAttributes, + lazy, + type MouseEvent, + Suspense, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import type { WorkspaceDocumentPreviewResult } from '../../../preload'; import { type DesignFileEntry, type DesignFileKind, useDesignFiles } from '../hooks/useDesignFiles'; +import { classifyMarkdownHref } from '../lib/markdown-links'; import { workspacePathComparisonKey } from '../lib/workspace-path'; import { formatIframeError, @@ -43,6 +53,38 @@ function escapeHtmlText(value: string): string { .replace(/'/g, '''); } +function MarkdownLink({ + href, + children, + node: _node, + ...props +}: AnchorHTMLAttributes & { node?: unknown }) { + const action = classifyMarkdownHref(href); + + if (action.kind === 'anchor') { + return ( + + {children} + + ); + } + + if (action.kind === 'external') { + const handleClick = (event: MouseEvent) => { + event.preventDefault(); + void window.codesign?.openExternal?.(action.url).catch(() => undefined); + }; + + return ( + + {children} + + ); + } + + return {children}; +} + function WorkspaceSection() { const t = useT(); const currentDesignId = useCodesignStore((s) => s.currentDesignId); @@ -541,7 +583,9 @@ function TextFilePreview({ ) : null} - {markdown.body} + + {markdown.body} + ) : (
 {
+  it('keeps in-document anchors clickable', () => {
+    expect(classifyMarkdownHref('#details')).toEqual({ kind: 'anchor', href: '#details' });
+  });
+
+  it('routes https URLs through the safe external-open path', () => {
+    expect(
+      classifyMarkdownHref('https://github.com/OpenCoworkAI/open-codesign/issues/339'),
+    ).toEqual({
+      kind: 'external',
+      url: 'https://github.com/OpenCoworkAI/open-codesign/issues/339',
+    });
+  });
+
+  it('normalizes surrounding whitespace on external URLs', () => {
+    expect(
+      classifyMarkdownHref('  https://github.com/OpenCoworkAI/open-codesign/releases  '),
+    ).toEqual({
+      kind: 'external',
+      url: 'https://github.com/OpenCoworkAI/open-codesign/releases',
+    });
+  });
+
+  it('blocks relative links that would otherwise navigate the app document', () => {
+    expect(classifyMarkdownHref('./other.md')).toEqual({ kind: 'blocked' });
+    expect(classifyMarkdownHref('/absolute/path')).toEqual({ kind: 'blocked' });
+  });
+
+  it('blocks unsafe protocols', () => {
+    expect(classifyMarkdownHref('javascript:alert(1)')).toEqual({ kind: 'blocked' });
+    expect(classifyMarkdownHref('file:///Users/user/.ssh/id_rsa')).toEqual({ kind: 'blocked' });
+    expect(classifyMarkdownHref('http://example.com')).toEqual({ kind: 'blocked' });
+  });
+});
diff --git a/apps/desktop/src/renderer/src/lib/markdown-links.ts b/apps/desktop/src/renderer/src/lib/markdown-links.ts
new file mode 100644
index 00000000..dade120f
--- /dev/null
+++ b/apps/desktop/src/renderer/src/lib/markdown-links.ts
@@ -0,0 +1,26 @@
+export type MarkdownHrefAction =
+  | { kind: 'anchor'; href: string }
+  | { kind: 'external'; url: string }
+  | { kind: 'blocked' };
+
+export function classifyMarkdownHref(rawHref: string | undefined): MarkdownHrefAction {
+  const href = rawHref?.trim();
+  if (!href) return { kind: 'blocked' };
+
+  if (href.startsWith('#')) {
+    return { kind: 'anchor', href };
+  }
+
+  let parsed: URL;
+  try {
+    parsed = new URL(href);
+  } catch {
+    return { kind: 'blocked' };
+  }
+
+  if (parsed.protocol !== 'https:') {
+    return { kind: 'blocked' };
+  }
+
+  return { kind: 'external', url: parsed.href };
+}

From 45a59e9110d0e1fcc83eb7499a89f48b2c19316f Mon Sep 17 00:00:00 2001
From: hqhq1025 <1506751656@qq.com>
Date: Tue, 19 May 2026 12:18:24 +0800
Subject: [PATCH 2/2] chore: add navigation boundary changeset

---
 .changeset/desktop-navigation-boundary.md | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 .changeset/desktop-navigation-boundary.md

diff --git a/.changeset/desktop-navigation-boundary.md b/.changeset/desktop-navigation-boundary.md
new file mode 100644
index 00000000..2a8d772d
--- /dev/null
+++ b/.changeset/desktop-navigation-boundary.md
@@ -0,0 +1,5 @@
+---
+"@open-codesign/desktop": patch
+---
+
+Harden desktop navigation so workspace Markdown links cannot replace the app window.