authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-10 00:36:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 00:36:04-07:00
logbd2346eda2e2c380d1844869c7fd16c279d2190f
tree3de314eede98077a9c723ef138be6a19a8895946
parent6d5ea190a4a54b01dfe6500e032526839e3258d1
signature Signed by PGP key B5690EEEBB952194

fix(server): refuse editor paths with line breaks or quotes when the editor is a Windows command shim (#17749)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

3 files changed, 115 insertions(+), 0 deletions(-)

apps/server/src/process/externalLauncher.test.ts+89
......@@ -7,7 +7,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
77import { assert, it } from "@effect/vitest";
88import * as ConfigProvider from "effect/ConfigProvider";
99import * as Deferred from "effect/Deferred";
10import * as Cause from "effect/Cause";
1011import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";
1113import * as Fiber from "effect/Fiber";
1214import * as FileSystem from "effect/FileSystem";
1315import * as Layer from "effect/Layer";
......@@ -1381,6 +1383,93 @@ it.effect("keeps scanning after the caller is interrupted and shares that scan",
13811383 );
13821384});
13831385
1386const launchWindowsShimEditor = (cwd: string) =>
1387 Effect.gen(function* () {
1388 const fileSystem = yield* FileSystem.FileSystem;
1389 const path = yield* Path.Path;
1390 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
1391 yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n");
1392 let spawned: ChildProcess.StandardCommand | undefined;
1393 const exit = yield* Effect.gen(function* () {
1394 const launcher = yield* ExternalLauncher.ExternalLauncher;
1395 return yield* launcher.launchEditor({ editor: "vscode", cwd }).pipe(Effect.exit);
1396 }).pipe(
1397 Effect.provide(
1398 layerTest({
1399 platform: "win32",
1400 env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" },
1401 resolveExecutable: (command) =>
1402 command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command,
1403 onSpawn: (command) => {
1404 spawned = command;
1405 },
1406 }),
1407 ),
1408 );
1409 return { exit, spawned };
1410 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
1411
1412it.effect.each([
1413 ["CRLF", "C:\\workspace\\file.ts\r\ncalc.exe"],
1414 ["LF", "C:\\workspace\\file.ts\ncalc.exe"],
1415 ["double quote", 'C:\\workspace\\file.ts" & calc.exe & "'],
1416] as const)("does not start a Windows command shim for a path with a $0", ([, cwd]) =>
1417 Effect.gen(function* () {
1418 const { exit, spawned } = yield* launchWindowsShimEditor(cwd);
1419 assert.equal(spawned, undefined);
1420 assert.isTrue(Exit.isFailure(exit));
1421 if (Exit.isFailure(exit)) {
1422 assert.instanceOf(
1423 Cause.squash(exit.cause),
1424 ExternalLauncher.ExternalLauncherUnsupportedTargetError,
1425 );
1426 }
1427 }),
1428);
1429
1430it.effect("starts a Windows command shim for unicode paths with cmd metacharacters", () =>
1431 Effect.gen(function* () {
1432 const { exit, spawned } = yield* launchWindowsShimEditor(
1433 "C:\\Users\\jö\\R&D (100%)\\naïve ^file!.ts:3:7",
1434 );
1435 assert.isTrue(Exit.isSuccess(exit));
1436 assert.ok(spawned);
1437 assert.equal(spawned.options.shell, true);
1438 assert.deepEqual(spawned.args, [
1439 '^"--goto^"',
1440 '^"C:\\Users\\jö\\R^&D^ ^(100^%^)\\naïve^ ^^file^!.ts:3:7^"',
1441 ]);
1442 }),
1443);
1444
1445it.effect.skipIf(windowsHost)("passes line breaks through to editors started without a shell", () =>
1446 Effect.gen(function* () {
1447 const fileSystem = yield* FileSystem.FileSystem;
1448 const path = yield* Path.Path;
1449 const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
1450 const zed = path.join(binDir, "zed");
1451 yield* fileSystem.writeFileString(zed, "#!/bin/sh\n");
1452 yield* fileSystem.chmod(zed, 0o755);
1453 let spawned: ChildProcess.StandardCommand | undefined;
1454 yield* Effect.gen(function* () {
1455 const launcher = yield* ExternalLauncher.ExternalLauncher;
1456 yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace/odd\nname.ts" });
1457 }).pipe(
1458 Effect.provide(
1459 layerTest({
1460 platform: "linux",
1461 env: { PATH: binDir, HOME: binDir },
1462 onSpawn: (command) => {
1463 spawned = command;
1464 },
1465 }),
1466 ),
1467 );
1468 assert.ok(spawned);
1469 assert.deepEqual(spawned.args, ["/workspace/odd\nname.ts"]);
1470 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1471);
1472
13841473it.effect("rejects unknown editors through the service API", () =>
13851474 Effect.gen(function* () {
13861475 const launcher = yield* ExternalLauncher.ExternalLauncher;
apps/server/src/process/externalLauncher.ts+13
......@@ -14,6 +14,7 @@ import {
1414 ExternalLauncherEditorSpawnError,
1515 ExternalLauncherUnknownEditorError,
1616 ExternalLauncherUnsupportedEditorError,
17 ExternalLauncherUnsupportedTargetError,
1718 type EditorId,
1819 type FileManagerRevealKind,
1920 type LaunchEditorInput,
......@@ -53,6 +54,7 @@ export {
5354 ExternalLauncherEditorSpawnError,
5455 ExternalLauncherUnknownEditorError,
5556 ExternalLauncherUnsupportedEditorError,
57 ExternalLauncherUnsupportedTargetError,
5658} from "@t3tools/contracts";
5759export type { LaunchEditorInput };
5860interface EditorLaunch {
......@@ -74,6 +76,14 @@ interface TargetPathAndPosition {
7476 readonly column: Option.Option<string>;
7577}
7678
79/**
80 * Windows command shims (`code.cmd`) forward arguments through `%*`, which
81 * cmd.exe parses a second time after the first escaping layer is gone: a
82 * line break ends the command there, and a double quote closes the quoting
83 * that keeps `&` or `|` literal. Neither can occur in a Windows path.
84 */
85// oxlint-disable-next-line no-control-regex
86const WINDOWS_SHIM_UNSAFE_ARG_PATTERN = /[\u0000-\u001f\u007f"]/;
7787const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;
7888const POWERSHELL_ARGUMENTS_PREFIX = [
7989 "-NoProfile",
......@@ -726,6 +736,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu
726736 }
727737
728738 const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env });
739 if (spawnCommand.shell && launch.args.some((arg) => WINDOWS_SHIM_UNSAFE_ARG_PATTERN.test(arg))) {
740 return yield* new ExternalLauncherUnsupportedTargetError({ editor: launch.editor });
741 }
729742 yield* launchAndUnref(
730743 {
731744 command: spawnCommand.command,
packages/contracts/src/editor.ts+13
......@@ -283,6 +283,18 @@ export class ExternalLauncherCommandNotFoundError extends Schema.TaggedError<Ext
283283 }
284284}
285285
286/** The target path cannot reach the editor intact, so it was not started. */
287export class ExternalLauncherUnsupportedTargetError extends Schema.TaggedError<ExternalLauncherUnsupportedTargetError>()(
288 "ExternalLauncherUnsupportedTargetError",
289 {
290 editor: EditorId,
291 },
292) {
293 override get message(): string {
294 return `${this.editor} cannot open a path containing line breaks or double quotes`;
295 }
296}
297
286298const ExternalLauncherSpawnFields = {
287299 command: Schema.String,
288300 args: Schema.Array(Schema.String),
......@@ -318,6 +330,7 @@ export const ExternalLauncherError = Schema.Union([
318330 ExternalLauncherUnknownEditorError,
319331 ExternalLauncherUnsupportedEditorError,
320332 ExternalLauncherCommandNotFoundError,
333 ExternalLauncherUnsupportedTargetError,
321334 ExternalLauncherBrowserSpawnError,
322335 ExternalLauncherEditorSpawnError,
323336]);