Skip to content

fix: open links in external browser instead of in-app#254

Merged
chuks-qua merged 6 commits intomainfrom
fix/links-open-in-exteral-browser
Apr 13, 2026
Merged

fix: open links in external browser instead of in-app#254
chuks-qua merged 6 commits intomainfrom
fix/links-open-in-exteral-browser

Conversation

@chuks-qua
Copy link
Copy Markdown
Contributor

@chuks-qua chuks-qua commented Apr 12, 2026

What

External links clicked inside the Electron app (markdown chat messages, target="_blank" anchors) now open in the system default browser instead of navigating within the app window.

Why

Electron's default behavior for <a target="_blank"> is to open a new BrowserWindow, causing links to render inside the app rather than the user's browser. This was confusing and broke the expected UX.

Key Changes

  • Renderer (MarkdownContent.tsx): Added onClick handler to markdown <a> elements that routes through desktopBridge.openExternalUrl() with a window.open() fallback for web mode
  • Main process (main.ts): Added setWindowOpenHandler (denies all new windows, opens URL externally) and will-navigate listener (blocks cross-origin navigation) as defense-in-depth safety nets
  • IPC handler: Expanded open-external-url protocol allowlist from https: only to https:, http:, and mailto:
  • Shared helper: Extracted openIfAllowed() to deduplicate protocol validation across all three call sites; uses canonicalized parsed.href to avoid parser-differential risk
  • Tests: 6 test cases covering https, http, mailto, javascript: blocking, data: URI blocking, and window.open fallback

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • External URL handling now supports https, http, and mailto with a safer click/fallback flow.
  • Security Improvements

    • Added a protocol allowlist and tightened window/navigation handling to block unsafe or renderer-initiated navigations.
  • Tests

    • Added tests verifying secure link handling and fallbacks for desktop/web environments.

- Use parsed.href instead of raw URL string in openIfAllowed to
  eliminate parser-differential risk
- Handle rejected shell.openExternal promise instead of voiding it
- Update stale preload JSDoc to reflect expanded protocol allowlist
- Add afterEach cleanup for desktopBridge mock in tests
- Add test case for data: URI scheme blocking
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 12, 2026

📝 Walkthrough

Walkthrough

Adds URL allowlist validation and centralized external-open handling; prevents renderer-initiated new windows and blocks cross-origin navigations in the desktop main process. Web markdown links now route through the desktop bridge when available, falling back to window.open.

Changes

Cohort / File(s) Summary
Desktop main security
apps/desktop/src/main/main.ts
Added openIfAllowed(url) allowlist (https/http/mailto) and switched open-external-url IPC to use it. Deny renderer window.open via webContents.setWindowOpenHandler and route allowed externals through the helper. Added will-navigate listener to block cross-origin navigations and open allowed external URLs instead.
Preload docs
apps/desktop/src/main/preload.ts
Updated desktopBridge.openExternalUrl documentation to reflect support for https, http, and mailto schemes (comment only).
Web markdown link handling & tests
apps/web/src/components/chat/MarkdownContent.tsx, apps/web/src/__tests__/MarkdownContent.test.tsx
Added click handler on markdown anchors to prevent default and call window.desktopBridge.openExternalUrl(safeHref) with fallback to window.open(..., "_blank", "noopener,noreferrer"). New tests verify allowed schemes (https/http/mailto) invoke bridge, while javascript:/data: do not; also tests fallback when bridge is absent.

Sequence Diagrams

sequenceDiagram
    participant User
    participant Renderer as Renderer<br/>(web content)
    participant Main as Desktop Main<br/>Process
    participant Security as openIfAllowed
    participant Shell as System Browser

    User->>Renderer: Click link or window.open()
    Renderer->>Main: (window.open blocked) send IPC or let handler run
    Main->>Security: Parse & validate URL (https/http/mailto)
    alt Allowed
        Security->>Shell: shell.openExternal(url)
    else Disallowed
        Security-->>Main: ignore / block
    end
Loading
sequenceDiagram
    participant User
    participant WebApp as Web App<br/>(MarkdownContent)
    participant ClickH as Click Handler
    participant Bridge as Desktop Bridge
    participant Browser as System Browser

    User->>WebApp: Click markdown link
    WebApp->>ClickH: onClick (preventDefault, get safeHref)
    alt Bridge available
        ClickH->>Bridge: openExternalUrl(safeHref)
        Bridge->>Browser: openExternal(safeHref)
    else Bridge absent
        ClickH->>Browser: window.open(safeHref, "_blank", "noopener,noreferrer")
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nibble at links, check each little part,
Allowing only schemes that are safe and smart,
No rogue windows opened, no stray navigation,
Desktop and web sing in tidy coordination.
Hop, click, secure — hooray for cautious art! 🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title 'fix: open links in external browser instead of in-app' is clear, concise, and accurately summarizes the main change of the pull request—routing external links to the system default browser rather than opening them within the app window.
Description check ✅ Passed The pull request description follows the template with clear 'What', 'Why', and 'Key Changes' sections, providing comprehensive detail about the modifications across renderer, main process, IPC handler, shared helper, and tests.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/links-open-in-exteral-browser

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src/main/main.ts`:
- Around line 200-206: The openIfAllowed function currently swallows rejections
from shell.openExternal; update openIfAllowed(url: string) so that when
parsed.protocol is allowed and shell.openExternal(parsed.href) rejects it logs
the error (use processLogger or console.error) with context including the URL
and protocol; ensure callers (setWindowOpenHandler, the 'will-navigate' event
handler, and the IPC bridge handler) rely on this function unchanged but benefit
from the surfaced errors for observability.

In `@apps/web/src/__tests__/MarkdownContent.test.tsx`:
- Around line 20-22: The test suite currently deletes window.desktopBridge in
afterEach but leaves global stubs like the one created by vi.stubGlobal('open',
...) inside a test; move the global cleanup call vi.unstubAllGlobals() into the
afterEach block so that afterEach both removes (window as unknown as
Record<string, unknown>).desktopBridge and calls vi.unstubAllGlobals(), ensuring
any vi.stubGlobal('open', ...) created in tests (or other global stubs) are
always unstubbed regardless of test outcome.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4b7d4fab-6fbc-4eac-a0f8-1fdb53433f19

📥 Commits

Reviewing files that changed from the base of the PR and between ae34100 and 3ccbe43.

📒 Files selected for processing (4)
  • apps/desktop/src/main/main.ts
  • apps/desktop/src/main/preload.ts
  • apps/web/src/__tests__/MarkdownContent.test.tsx
  • apps/web/src/components/chat/MarkdownContent.tsx

Comment thread apps/desktop/src/main/main.ts
Comment thread apps/web/src/__tests__/MarkdownContent.test.tsx
- Replace swallowed .catch(() => {}) with console.error logging in
  openIfAllowed so failures (e.g. no default browser) are observable
- Move vi.unstubAllGlobals() into afterEach so global stubs are always
  cleaned up regardless of test outcome
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src/main/main.ts`:
- Around line 205-206: The catch handler on shell.openExternal is currently
logging the full user-controlled URL (parsed.href); change the error log to
avoid leaking the full target by logging parsed.protocol and a redacted target
instead (e.g., use parsed.hostname or a fixed "<redacted>" placeholder or a
masked version that strips query and fragment) in the catch for
shell.openExternal inside openIfAllowed, ensuring the console.error no longer
includes parsed.href but only protocol + redactedTarget along with the error
object.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c9cdd93b-029c-474f-9993-96180f1f3a27

📥 Commits

Reviewing files that changed from the base of the PR and between 3ccbe43 and 8ac70e9.

📒 Files selected for processing (2)
  • apps/desktop/src/main/main.ts
  • apps/web/src/__tests__/MarkdownContent.test.tsx
✅ Files skipped from review due to trivial changes (1)
  • apps/web/src/tests/MarkdownContent.test.tsx

Comment on lines +205 to +206
shell.openExternal(parsed.href).catch((err: unknown) => {
console.error(`[openIfAllowed] Failed to open ${parsed.protocol} URL: ${parsed.href}`, err);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Redact the URL before logging external-open failures.

Line 206 logs the full parsed.href. These values come from user-controlled links and can include signed query params, mailto: recipients, or message bodies, so a failed open currently leaks that data into app logs. Log only the protocol plus a redacted target.

🛠️ Minimal fix
     if (EXTERNAL_PROTOCOLS.has(parsed.protocol)) {
       shell.openExternal(parsed.href).catch((err: unknown) => {
-        console.error(`[openIfAllowed] Failed to open ${parsed.protocol} URL: ${parsed.href}`, err);
+        const target =
+          parsed.protocol === "mailto:"
+            ? "mailto:[redacted]"
+            : `${parsed.protocol}//${parsed.host}`;
+        console.error("[openIfAllowed] Failed to open external URL", {
+          protocol: parsed.protocol,
+          target,
+          err,
+        });
       });
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/main.ts` around lines 205 - 206, The catch handler on
shell.openExternal is currently logging the full user-controlled URL
(parsed.href); change the error log to avoid leaking the full target by logging
parsed.protocol and a redacted target instead (e.g., use parsed.hostname or a
fixed "<redacted>" placeholder or a masked version that strips query and
fragment) in the catch for shell.openExternal inside openIfAllowed, ensuring the
console.error no longer includes parsed.href but only protocol + redactedTarget
along with the error object.

@chuks-qua chuks-qua merged commit 0ed6350 into main Apr 13, 2026
7 checks passed
@chuks-qua chuks-qua deleted the fix/links-open-in-exteral-browser branch April 13, 2026 07:05
@mze-bot mze-bot bot mentioned this pull request Apr 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant