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/spector/CHANGELOG.md b/packages/spector/CHANGELOG.md index 1955f3658b6..9a7150da209 100644 --- a/packages/spector/CHANGELOG.md +++ b/packages/spector/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log - @typespec/spector +## 0.1.0-alpha.27 + +### Bug Fixes + +- [#11274](https://github.com/microsoft/typespec/pull/11274) Bind the mock server to the loopback interface (`127.0.0.1`) so the unauthenticated `/.admin/stop` endpoint can no longer be reached by other hosts on the network. The server is only reachable from the local host. + + ## 0.1.0-alpha.26 ### Bug Fixes diff --git a/packages/spector/docs/using-spector.md b/packages/spector/docs/using-spector.md index 96651da9bc6..bd62bb6a25f 100644 --- a/packages/spector/docs/using-spector.md +++ b/packages/spector/docs/using-spector.md @@ -34,6 +34,9 @@ tsp-spector serve ./path/to/scenarios --port 1234 tsp-spector serve ./path/to/scenarios --coverageFile ./path/to/spector-coverage.json ``` +> [!NOTE] +> The server always binds to the loopback interface (`127.0.0.1`), so it is only reachable from the local host and is not exposed to other machines on the network. + Alternative to start in background ```bash diff --git a/packages/spector/package.json b/packages/spector/package.json index 951d79b41a5..53edda6c41d 100644 --- a/packages/spector/package.json +++ b/packages/spector/package.json @@ -1,6 +1,6 @@ { "name": "@typespec/spector", - "version": "0.1.0-alpha.26", + "version": "0.1.0-alpha.27", "description": "Typespec Core Tool to validate, run mock api, collect coverage.", "exports": { ".": { diff --git a/packages/spector/src/server/server.ts b/packages/spector/src/server/server.ts index 37570bd0eb7..274e728914b 100644 --- a/packages/spector/src/server/server.ts +++ b/packages/spector/src/server/server.ts @@ -10,6 +10,13 @@ export interface MockApiServerConfig { port: number; } +/** + * The mock server always binds to the loopback interface so it is only reachable + * from the local host. This keeps the unauthenticated admin endpoints (e.g. the + * server stop signal) from being exposed to other hosts on the network. + */ +const LOOPBACK_HOST = "127.0.0.1"; + const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => { logger.error("Error", err); @@ -85,14 +92,14 @@ export class MockApiServer { this.app.use(errorHandler); return new Promise((resolve, reject) => { - const server = this.app.listen(this.config.port, () => { + const server = this.app.listen(this.config.port, LOOPBACK_HOST, () => { const resolvedPort = getPort(server); if (!resolvedPort) { logger.error("Failed to resolve port"); reject(new Error("Failed to resolve port")); return; } - logger.info(`Started server on ${resolvedPort}`); + logger.info(`Started server on ${LOOPBACK_HOST}:${resolvedPort}`); resolve(resolvedPort); }); diff --git a/packages/typespec-vscode/CHANGELOG.md b/packages/typespec-vscode/CHANGELOG.md index 17f4b06c10e..63f557d9396 100644 --- a/packages/typespec-vscode/CHANGELOG.md +++ b/packages/typespec-vscode/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log - typespec-vscode +## 1.14.1 + +### Bug Fixes + +- [#11275](https://github.com/microsoft/typespec/pull/11275) 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. + + ## 1.14.0 No changes, version bump only. diff --git a/packages/typespec-vscode/package.json b/packages/typespec-vscode/package.json index a97a08f2891..d0ee77372ee 100644 --- a/packages/typespec-vscode/package.json +++ b/packages/typespec-vscode/package.json @@ -1,7 +1,7 @@ { "name": "typespec-vscode", "private": true, - "version": "1.14.0", + "version": "1.14.1", "author": "Microsoft Corporation", "description": "TypeSpec language support for VS Code", "homepage": "https://typespec.io", @@ -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", + ]); + }); +});