Skip to content

Commit b656394

Browse files
committed
Open Windows transcript URLs without cmd /c
cmd.exe re-parses the assembled command line, so &, | and && in an attacker-influenceable transcript URL would execute as command separators. Route win32 through rundll32 url.dll,FileProtocolHandler with the URL as a plain argv element instead; no shell is involved on any platform.
1 parent c59b60c commit b656394

3 files changed

Lines changed: 41 additions & 9 deletions

File tree

‎docs/TUI.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -829,8 +829,8 @@ running its own selection. Two chords cover remaining copy needs:
829829
support OSC-8, so Cmd+click does nothing there; iTerm2 3.5+, Ghostty,
830830
WezTerm, Kitty, and VS Code support it.
831831
- **Linux/Windows: Ctrl+click.** The app opens the URL through the
832-
platform opener (`open` on macOS as fallback, `xdg-open`, `cmd /c
833-
start`).
832+
platform opener (`open` on macOS as fallback, `xdg-open`, `rundll32
833+
url.dll,FileProtocolHandler` — argv spawns, never through a shell).
834834
- **Right-click safety.** The open gesture requires a left (button-0)
835835
press with the modifier held, so Ctrl+right-click never opens a URL —
836836
context menus stay safe.
@@ -921,7 +921,7 @@ terminal. It cannot observe:
921921
Ctrl+drag do not, non-`http(s)` never opens) with a mocked opener, but
922922
only a real terminal can show whether it delivers the held Ctrl on motion
923923
and press events, whether it honors OSC-8 for Cmd+click, or resolves the
924-
`open`/`xdg-open` spawn into a browser.
924+
`open`/`xdg-open`/`rundll32` spawn into a browser.
925925
- **The system clipboard.** `system-clipboard.ts`'s helper-binary spawns and
926926
OSC 52 fallback are exercised with mocked spawn functions in tests; no
927927
test round-trips through a real `pbcopy`/`xclip`/terminal clipboard.

‎src/tui/url-links.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,21 @@ export function isUrlOpenClick(
166166

167167
export type UrlOpener = (url: string) => void;
168168

169+
/**
170+
* Argv for opening a URL with the platform handler, without a shell. Windows
171+
* must never route through `cmd /c start`: cmd.exe re-parses the assembled
172+
* command line, so `&`, `|` and `&&` in an attacker-influenceable transcript
173+
* URL would execute as command separators. `rundll32 url.dll,FileProtocolHandler`
174+
* takes the URL as a plain argv element instead.
175+
*/
176+
export function platformUrlCommand(platform: string, url: string): string[] {
177+
if (platform === "darwin") return ["open", url];
178+
if (platform === "win32") return ["rundll32", "url.dll,FileProtocolHandler", url];
179+
return ["xdg-open", url];
180+
}
181+
169182
function defaultUrlOpener(url: string): void {
170-
const command =
171-
process.platform === "darwin"
172-
? ["open", url]
173-
: process.platform === "win32"
174-
? ["cmd", "/c", "start", "", url]
175-
: ["xdg-open", url];
183+
const command = platformUrlCommand(process.platform, url);
176184
try {
177185
Bun.spawn(command, {
178186
stdout: "ignore",

‎tests/unit/tui/url-links.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
isOpenableUrl,
55
isUrlOpenClick,
66
openUrl,
7+
platformUrlCommand,
78
setUrlOpener,
89
resetUrlOpener,
910
splitLinkSpans,
@@ -123,6 +124,29 @@ describe("isUrlOpenClick", () => {
123124
});
124125
});
125126

127+
describe("platformUrlCommand", () => {
128+
test("windows opens without cmd /c so metacharacters never parse", () => {
129+
const url = "https://example.com/x?a=1&b=2";
130+
expect(platformUrlCommand("win32", url)).toEqual([
131+
"rundll32",
132+
"url.dll,FileProtocolHandler",
133+
url,
134+
]);
135+
expect(platformUrlCommand("win32", url)).not.toContain("cmd");
136+
});
137+
138+
test("darwin and linux use their openers with the URL as argv", () => {
139+
expect(platformUrlCommand("darwin", "https://example.com")).toEqual([
140+
"open",
141+
"https://example.com",
142+
]);
143+
expect(platformUrlCommand("linux", "https://example.com")).toEqual([
144+
"xdg-open",
145+
"https://example.com",
146+
]);
147+
});
148+
});
149+
126150
describe("openUrl", () => {
127151
test("calls the opener with the exact URL (mocked)", () => {
128152
const calls: string[] = [];

0 commit comments

Comments
 (0)