From 74839fe4efca876c83b4bba51c98020f89ee65ef Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 16 Jul 2026 12:49:04 -0400 Subject: [PATCH] fix(typespec-vscode): prevent shell injection in tsp compile task Use vscode.ProcessExecution with an argument array instead of vscode.ShellExecution so workspace file paths and task arguments are never interpreted by the OS shell. --- ...vider-shell-injection-2026-7-16-12-45-0.md | 7 +++ packages/typespec-vscode/package.json | 7 ++- packages/typespec-vscode/src/task-command.ts | 23 ++++++++ packages/typespec-vscode/src/task-provider.ts | 32 ++++++----- .../test/unit/task-command.test.ts | 56 +++++++++++++++++++ 5 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 .chronus/changes/fix-task-provider-shell-injection-2026-7-16-12-45-0.md create mode 100644 packages/typespec-vscode/src/task-command.ts create mode 100644 packages/typespec-vscode/test/unit/task-command.test.ts diff --git a/.chronus/changes/fix-task-provider-shell-injection-2026-7-16-12-45-0.md b/.chronus/changes/fix-task-provider-shell-injection-2026-7-16-12-45-0.md new file mode 100644 index 00000000000..c428d2c5fd5 --- /dev/null +++ b/.chronus/changes/fix-task-provider-shell-injection-2026-7-16-12-45-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "typespec-vscode" +--- + +Fix a shell command injection in the tsp compile task provider. Tasks now run via `vscode.ProcessExecution` with arguments passed as an array instead of `vscode.ShellExecution`, so workspace file paths and task arguments are no longer interpreted by the OS shell. The task `args` is now specified as an array of arguments. diff --git a/packages/typespec-vscode/package.json b/packages/typespec-vscode/package.json index a97a08f2891..dcbae222e41 100644 --- a/packages/typespec-vscode/package.json +++ b/packages/typespec-vscode/package.json @@ -249,8 +249,11 @@ "description": "The path to trigger tsp compile" }, "args": { - "type": "string", - "description": "The arguments to tsp compile" + "type": "array", + "items": { + "type": "string" + }, + "description": "The arguments passed to tsp compile, where each element is a single argument." } } } diff --git a/packages/typespec-vscode/src/task-command.ts b/packages/typespec-vscode/src/task-command.ts new file mode 100644 index 00000000000..7ec7bbb9909 --- /dev/null +++ b/packages/typespec-vscode/src/task-command.ts @@ -0,0 +1,23 @@ +import type { Executable } from "vscode-languageclient/node"; +import { VSCodeVariableResolver } from "./vscode-variable-resolver.js"; + +/** + * Build the command and argument array for a tsp compile task. Arguments are passed + * as an array (never concatenated into a shell string) so they can be executed + * without invoking a shell, avoiding command injection through workspace paths or + * task arguments. + */ +export function resolveTaskCommand( + absoluteTargetPath: string, + args: string[], + cli: Executable, + workspaceFolder: string, +): { command: string; args: string[] } { + const variableResolver = new VSCodeVariableResolver({ + workspaceFolder, + workspaceRoot: workspaceFolder, // workspaceRoot is deprecated but we still support it for backwards compatibility. + }); + const resolve = (value: string) => variableResolver.resolve(value); + const commandArgs = [...(cli.args ?? []), "compile", absoluteTargetPath, ...args].map(resolve); + return { command: resolve(cli.command), args: commandArgs }; +} diff --git a/packages/typespec-vscode/src/task-provider.ts b/packages/typespec-vscode/src/task-provider.ts index 89722edffef..430f5ae44e6 100644 --- a/packages/typespec-vscode/src/task-provider.ts +++ b/packages/typespec-vscode/src/task-provider.ts @@ -4,6 +4,7 @@ import { Executable } from "vscode-languageclient/node"; import { StartFileName } from "./const.js"; import logger from "./log/logger.js"; import { normalizeSlashes } from "./path-utils.js"; +import { resolveTaskCommand } from "./task-command.js"; import { resolveTypeSpecCli } from "./tsp-executable-resolver.js"; import { VSCodeVariableResolver } from "./vscode-variable-resolver.js"; @@ -70,21 +71,26 @@ function getTaskPath(targetPath: string): { absoluteTargetPath: string; workspac return { absoluteTargetPath: targetPath, workspaceFolder }; } +/** + * Create a tsp compile {@link vscode.Task} that runs via {@link vscode.ProcessExecution} + * (no shell) so workspace paths and task arguments cannot be interpreted as shell + * commands. + */ function createTaskInternal( name: string, absoluteTargetPath: string, - args: string, + args: string[], cli: Executable, workspaceFolder: string, ) { - let cmd = `${cli.command} ${cli.args?.join(" ") ?? ""} compile "${absoluteTargetPath}" ${args}`; - const variableResolver = new VSCodeVariableResolver({ + const { command, args: commandArgs } = resolveTaskCommand( + absoluteTargetPath, + args, + cli, workspaceFolder, - workspaceRoot: workspaceFolder, // workspaceRoot is deprecated but we still support it for backwards compatibility. - }); - cmd = variableResolver.resolve(cmd); + ); logger.debug( - `Command of tsp compile task "${name}" is resolved to: ${cmd} with cwd "${workspaceFolder}"`, + `Command of tsp compile task "${name}" is resolved to: ${command} ${commandArgs.join(" ")} with cwd "${workspaceFolder}"`, ); return new vscode.Task( { @@ -96,18 +102,18 @@ function createTaskInternal( name, "tsp", workspaceFolder - ? new vscode.ShellExecution(cmd, { cwd: workspaceFolder }) - : new vscode.ShellExecution(cmd), + ? new vscode.ProcessExecution(command, commandArgs, { cwd: workspaceFolder }) + : new vscode.ProcessExecution(command, commandArgs), ); } -async function createTask(name: string, targetPath: string, args?: string) { +async function createTask(name: string, targetPath: string, args?: string[]) { const { absoluteTargetPath, workspaceFolder } = getTaskPath(targetPath); const cli = await resolveTypeSpecCli(absoluteTargetPath); if (!cli) { return undefined; } - return await createTaskInternal(name, absoluteTargetPath, args ?? "", cli, workspaceFolder); + return await createTaskInternal(name, absoluteTargetPath, args ?? [], cli, workspaceFolder); } async function createBuiltInTasks(targetPath: string): Promise { @@ -117,8 +123,8 @@ async function createBuiltInTasks(targetPath: string): Promise { return []; } return [ - { name: `compile - ${targetPath}`, args: "" }, - { name: `watch - ${targetPath}`, args: "--watch" }, + { name: `compile - ${targetPath}`, args: [] }, + { name: `watch - ${targetPath}`, args: ["--watch"] }, ].map(({ name, args }) => { return createTaskInternal(name, absoluteTargetPath, args, cli, workspaceFolder); }); diff --git a/packages/typespec-vscode/test/unit/task-command.test.ts b/packages/typespec-vscode/test/unit/task-command.test.ts new file mode 100644 index 00000000000..08f819dec4c --- /dev/null +++ b/packages/typespec-vscode/test/unit/task-command.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { Executable } from "vscode-languageclient/node"; +import { resolveTaskCommand } from "../../src/task-command.js"; + +describe("resolveTaskCommand", () => { + const cli: Executable = { command: "node", args: ["/compiler/cmd/tsp.js"] }; + + it("passes the target path as a single literal argument", () => { + const { command, args } = resolveTaskCommand("/work/main.tsp", [], cli, "/work"); + expect(command).toBe("node"); + expect(args).toEqual(["/compiler/cmd/tsp.js", "compile", "/work/main.tsp"]); + }); + + it("does not interpret shell metacharacters in the path (injection is neutralized)", () => { + const malicious = "/work/$(rm -rf ~)`whoami`; echo pwned/main.tsp"; + const { args } = resolveTaskCommand(malicious, [], cli, "/work"); + // The whole path stays a single argument, untouched by any shell parsing. + expect(args).toContain(malicious); + expect(args).toEqual(["/compiler/cmd/tsp.js", "compile", malicious]); + }); + + it("appends the task arguments verbatim", () => { + const { args } = resolveTaskCommand( + "/work/main.tsp", + ["--watch", "--option", "out=/my folder"], + cli, + "/work", + ); + expect(args).toEqual([ + "/compiler/cmd/tsp.js", + "compile", + "/work/main.tsp", + "--watch", + "--option", + "out=/my folder", + ]); + }); + + it("resolves ${workspaceFolder} variables in each element", () => { + const cliWithVar: Executable = { command: "node", args: ["${workspaceFolder}/tsp.js"] }; + const { command, args } = resolveTaskCommand( + "${workspaceFolder}/main.tsp", + ["--output-dir", "${workspaceFolder}/out"], + cliWithVar, + "/work", + ); + expect(command).toBe("node"); + expect(args).toEqual([ + "/work/tsp.js", + "compile", + "/work/main.tsp", + "--output-dir", + "/work/out", + ]); + }); +});