Skip to content
Merged
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
17 changes: 16 additions & 1 deletion apps/desktop/src/lib/trpc/routers/external/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { clipboard, shell } from "electron";
import { localDb } from "main/lib/local-db";
import { externalUrlLogLabel, isSafeExternalUrl } from "main/lib/safe-url";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { getWorkspace } from "../workspaces/utils/db-helpers";
Expand Down Expand Up @@ -93,12 +94,26 @@ async function openPathInApp(
export const createExternalRouter = () => {
return router({
openUrl: publicProcedure.input(z.string()).mutation(async ({ input }) => {
if (!isSafeExternalUrl(input)) {
console.warn(
"[external/openUrl] Blocked unsafe URL scheme:",
externalUrlLogLabel(input),
);
throw new TRPCError({
code: "BAD_REQUEST",
message: "URL scheme not allowed",
});
}
try {
await shell.openExternal(input);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
console.error("[external/openUrl] Failed to open URL:", input, error);
console.error(
"[external/openUrl] Failed to open URL:",
externalUrlLogLabel(input),
error,
);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: errorMessage,
Expand Down
9 changes: 6 additions & 3 deletions apps/desktop/src/main/lib/browser/browser-manager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EventEmitter } from "node:events";
import { clipboard, Menu, shell, webContents } from "electron";
import { clipboard, Menu, webContents } from "electron";
import { safeOpenExternal } from "main/lib/safe-url";

interface ConsoleEntry {
level: "log" | "warn" | "error" | "info" | "debug";
Expand Down Expand Up @@ -126,7 +127,9 @@ class BrowserManager extends EventEmitter {
menuItems.push(
{
label: "Open Link in Default Browser",
click: () => shell.openExternal(linkURL),
click: () => {
void safeOpenExternal(linkURL);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 22, 2026

Choose a reason for hiding this comment

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

P2: Unhandled promise rejection: void safeOpenExternal(linkURL) discards the promise. If shell.openExternal throws an OS error, the rejection is unhandled, which can crash the Electron main process. Add a .catch() handler to log the failure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/main/lib/browser/browser-manager.ts, line 131:

<comment>Unhandled promise rejection: `void safeOpenExternal(linkURL)` discards the promise. If `shell.openExternal` throws an OS error, the rejection is unhandled, which can crash the Electron main process. Add a `.catch()` handler to log the failure.</comment>

<file context>
@@ -126,7 +127,9 @@ class BrowserManager extends EventEmitter {
 						label: "Open Link in Default Browser",
-						click: () => shell.openExternal(linkURL),
+						click: () => {
+							void safeOpenExternal(linkURL);
+						},
 					},
</file context>
Suggested change
void safeOpenExternal(linkURL);
safeOpenExternal(linkURL).catch((err) => {
console.warn("[BrowserManager] openExternal failed:", err);
});
Fix with Cubic

},
},
Comment on lines +130 to 133
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Unhandled promise rejection from safeOpenExternal

void safeOpenExternal(linkURL) discards the returned promise. If shell.openExternal throws internally (e.g., OS error), the rejection is silently swallowed and becomes an unhandled promise rejection. A .catch() or try/catch inside the click handler would make failure more observable. The same applies to line 200 for Open Page in Default Browser.

Suggested change
click: () => {
void safeOpenExternal(linkURL);
},
},
click: () => {
safeOpenExternal(linkURL).catch((err) => {
console.warn("[BrowserManager] openExternal failed:", err);
});
},
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/lib/browser/browser-manager.ts
Line: 130-133

Comment:
**Unhandled promise rejection from `safeOpenExternal`**

`void safeOpenExternal(linkURL)` discards the returned promise. If `shell.openExternal` throws internally (e.g., OS error), the rejection is silently swallowed and becomes an unhandled promise rejection. A `.catch()` or `try/catch` inside the click handler would make failure more observable. The same applies to line 200 for `Open Page in Default Browser`.

```suggestion
						click: () => {
							safeOpenExternal(linkURL).catch((err) => {
								console.warn("[BrowserManager] openExternal failed:", err);
							});
						},
```

How can I resolve this? If you propose a fix, please make it concise.

{
label: "Open Link as New Split",
Expand Down Expand Up @@ -194,7 +197,7 @@ class BrowserManager extends EventEmitter {
label: "Open Page in Default Browser",
click: () => {
if (pageURL && pageURL !== "about:blank") {
shell.openExternal(pageURL);
void safeOpenExternal(pageURL);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 22, 2026

Choose a reason for hiding this comment

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

P2: Same unhandled promise rejection issue: void safeOpenExternal(pageURL) discards the promise. Add a .catch() to prevent a potential Electron main-process crash on OS errors.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/main/lib/browser/browser-manager.ts, line 200:

<comment>Same unhandled promise rejection issue: `void safeOpenExternal(pageURL)` discards the promise. Add a `.catch()` to prevent a potential Electron main-process crash on OS errors.</comment>

<file context>
@@ -194,7 +197,7 @@ class BrowserManager extends EventEmitter {
 						click: () => {
 							if (pageURL && pageURL !== "about:blank") {
-								shell.openExternal(pageURL);
+								void safeOpenExternal(pageURL);
 							}
 						},
</file context>
Suggested change
void safeOpenExternal(pageURL);
safeOpenExternal(pageURL).catch((err) => {
console.warn("[BrowserManager] openExternal failed:", err);
});
Fix with Cubic

}
Comment on lines 198 to 201
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Same unhandled rejection applies to Open Page in Default Browser

Same pattern as the "Open Link" case above — errors from shell.openExternal will become unhandled promise rejections.

Suggested change
click: () => {
if (pageURL && pageURL !== "about:blank") {
shell.openExternal(pageURL);
void safeOpenExternal(pageURL);
}
click: () => {
if (pageURL && pageURL !== "about:blank") {
safeOpenExternal(pageURL).catch((err) => {
console.warn("[BrowserManager] openExternal failed:", err);
});
}
},
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/lib/browser/browser-manager.ts
Line: 198-201

Comment:
**Same unhandled rejection applies to `Open Page in Default Browser`**

Same pattern as the "Open Link" case above — errors from `shell.openExternal` will become unhandled promise rejections.

```suggestion
						click: () => {
							if (pageURL && pageURL !== "about:blank") {
								safeOpenExternal(pageURL).catch((err) => {
									console.warn("[BrowserManager] openExternal failed:", err);
								});
							}
						},
```

How can I resolve this? If you propose a fix, please make it concise.

},
enabled: !!pageURL && pageURL !== "about:blank",
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/lib/safe-url/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
externalUrlLogLabel,
isSafeExternalUrl,
safeOpenExternal,
} from "./safe-url";
46 changes: 46 additions & 0 deletions apps/desktop/src/main/lib/safe-url/safe-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "bun:test";
import { externalUrlLogLabel, isSafeExternalUrl } from "./safe-url";

describe("isSafeExternalUrl", () => {
it("allows http, https, and mailto URLs", () => {
expect(isSafeExternalUrl("http://example.com")).toBe(true);
expect(isSafeExternalUrl("https://example.com/path?q=1")).toBe(true);
expect(isSafeExternalUrl("mailto:user@example.com")).toBe(true);
expect(isSafeExternalUrl("HTTPS://EXAMPLE.COM")).toBe(true);
});

it("blocks file, javascript, data, and custom-scheme URLs", () => {
expect(
isSafeExternalUrl("file:///System/Applications/Calculator.app"),
).toBe(false);
expect(isSafeExternalUrl("file:///etc/passwd")).toBe(false);
expect(isSafeExternalUrl("javascript:alert(1)")).toBe(false);
expect(isSafeExternalUrl("data:text/html,<script>alert(1)</script>")).toBe(
false,
);
expect(isSafeExternalUrl("vscode://open?url=evil")).toBe(false);
expect(isSafeExternalUrl("ssh://user@host")).toBe(false);
expect(isSafeExternalUrl("ftp://example.com")).toBe(false);
});

it("blocks malformed input", () => {
expect(isSafeExternalUrl("")).toBe(false);
expect(isSafeExternalUrl("not a url")).toBe(false);
expect(isSafeExternalUrl("/etc/passwd")).toBe(false);
});
});
Comment on lines +1 to +31
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 safeOpenExternal is untested

The test suite only exercises isSafeExternalUrl. safeOpenExternal (the public-facing wrapper used in browser-manager.ts) is not covered at all. While it is a thin wrapper, testing it with a mocked shell would verify that:

  1. A safe URL results in shell.openExternal being called and true returned.
  2. A blocked URL results in shell.openExternal NOT being called and false returned.

This gap means a future refactor that accidentally calls openExternal before the guard would not be caught by tests.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/lib/safe-url/safe-url.test.ts
Line: 1-31

Comment:
**`safeOpenExternal` is untested**

The test suite only exercises `isSafeExternalUrl`. `safeOpenExternal` (the public-facing wrapper used in `browser-manager.ts`) is not covered at all. While it is a thin wrapper, testing it with a mocked `shell` would verify that:
1. A safe URL results in `shell.openExternal` being called and `true` returned.
2. A blocked URL results in `shell.openExternal` NOT being called and `false` returned.

This gap means a future refactor that accidentally calls `openExternal` before the guard would not be caught by tests.

How can I resolve this? If you propose a fix, please make it concise.


describe("externalUrlLogLabel", () => {
it("returns only the scheme, never the full URL", () => {
expect(externalUrlLogLabel("https://example.com/path?token=secret")).toBe(
"https:",
);
expect(externalUrlLogLabel("file:///etc/passwd")).toBe("file:");
expect(externalUrlLogLabel("mailto:user@example.com")).toBe("mailto:");
});

it("returns sentinels for empty and malformed input", () => {
expect(externalUrlLogLabel("")).toBe("empty");
expect(externalUrlLogLabel("not a url")).toBe("malformed");
});
});
53 changes: 53 additions & 0 deletions apps/desktop/src/main/lib/safe-url/safe-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { shell } from "electron";

/**
* Schemes safe to hand to Electron's `shell.openExternal`.
* Anything else (file:, javascript:, custom handlers, etc.) can execute
* binaries or scripts via the OS URL handler registry.
*/
const ALLOWED_SCHEMES = new Set(["http:", "https:", "mailto:"]);

export function isSafeExternalUrl(url: string): boolean {
if (typeof url !== "string" || url.length === 0) return false;
try {
return ALLOWED_SCHEMES.has(new URL(url).protocol);
} catch {
return false;
}
}

export function externalUrlLogLabel(url: string): string {
if (typeof url !== "string" || url.length === 0) return "empty";
try {
return new URL(url).protocol || "unknown:";
} catch {
return "malformed";
}
}

/**
* Wraps `shell.openExternal` with a scheme allowlist. Returns false and
* refuses to dispatch when the URL is not http(s)/mailto. Catches
* `shell.openExternal` rejections so callers can fire-and-forget without
* risking an unhandled rejection in the Electron main process.
*/
export async function safeOpenExternal(url: string): Promise<boolean> {
if (!isSafeExternalUrl(url)) {
console.warn(
"[safeOpenExternal] blocked unsafe URL scheme:",
externalUrlLogLabel(url),
);
return false;
}
try {
await shell.openExternal(url);
return true;
} catch (error) {
console.error(
"[safeOpenExternal] openExternal failed:",
externalUrlLogLabel(url),
error,
);
return false;
}
}
Loading