From 30d6f6598dd796e2d6aea038139d29b91e6a2da7 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 16 Jul 2026 17:57:11 -0400 Subject: [PATCH 1/3] fix(spector): prevent unauthenticated remote server stop (#11274) ## Summary Hotfix for a reported **unauthenticated remote stop** vulnerability in `@typespec/spector`. The mock server registered `POST /.admin/stop` with no authentication and bound to all interfaces (`0.0.0.0`) by default, so any network-reachable client could terminate the server process with a single request. ## Fix The mock server is only ever used locally, so it now **always binds to the loopback interface (`127.0.0.1`)**. This makes the admin/stop endpoint unreachable from other hosts, which fully closes the reported vector without needing any per-request origin/host check. ## Notes - Verified with Node's `fetch` that clients still connect over `localhost` even on IPv6-first (`localhost` -> `::1`) resolution, so existing localhost-based test workflows keep working. - `tsp-spector server stop` (which posts to `http://localhost:/.admin/stop`) continues to work unchanged. - Added a changelog entry (`fix`). --- .../fix-spector-stop-loopback-2026-7-16-12-15-0.md | 7 +++++++ packages/spector/docs/using-spector.md | 3 +++ packages/spector/src/server/server.ts | 11 +++++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 .chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md diff --git a/.chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md b/.chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md new file mode 100644 index 00000000000..47f3be07419 --- /dev/null +++ b/.chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/spector" +--- + +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. 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/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); }); From ae3dd500f6f80bf684c20a0290787cb971666a19 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 16 Jul 2026 18:06:57 -0400 Subject: [PATCH 2/3] fix(typespec-vscode): prevent shell injection in tsp compile task (#11275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes a shell command injection vulnerability in the TypeSpec VS Code task provider. ### Data flow (source → sink) - `extension.ts` registers `createTaskProvider()` on activation (default-on). - `task-provider.ts` — `vscode.workspace.findFiles('**/main.tsp', ...)` collects workspace paths, including **attacker-controllable directory names**; `task.definition.path`/`args` also come from user-authored `tasks.json`. - The resolved path/args were interpolated (wrapped only in double quotes) into a command string and passed to `new vscode.ShellExecution(cmd, ...)`, which executes it via the OS shell → command injection. ### Fix Replace `vscode.ShellExecution` with `vscode.ProcessExecution`, passing arguments as an array so no shell is involved and no escaping is required. - New `src/task-command.ts` with pure, testable helpers: - `splitArgs` — **quote-aware** tokenizer (respects single/double quotes) so legitimate `tasks.json` args with spaces keep working; performs no shell expansion. - `resolveTaskCommand` — builds `{ command, args[] }` as `[...cli.args, "compile", absoluteTargetPath, ...splitArgs(args)]`, resolving `${...}` variables per element. - `createTaskInternal` now uses these helpers + `vscode.ProcessExecution` (both `cwd` and no-`cwd` branches). Logic was extracted into a separate module because the unit-test environment has no real `vscode` module. ### Behavior note Shell features (`$VAR`, `&&`, globbing) in `tasks.json` args are no longer shell-interpreted — the intended hardening. Quoting to group tokens with spaces still works. ### Tests - New `test/unit/task-command.test.ts` (9 tests): empty/`--watch` args, quoted args with spaces, `${workspaceFolder}` resolution, and an injection path (`$(rm -rf ~)`...) verified to remain a single literal argument. - Full unit suite passes (11 tests); Prettier + oxlint clean. --- ...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", + ]); + }); +}); From 73c1b0dd189c5e0f396337ed18b4fdbfaeb220f1 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 16 Jul 2026 18:29:24 -0400 Subject: [PATCH 3/3] Bump versions for hotfixes (#11281) --- .../changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md | 7 ------- packages/spector/CHANGELOG.md | 7 +++++++ packages/spector/package.json | 2 +- packages/typespec-vscode/CHANGELOG.md | 7 +++++++ packages/typespec-vscode/package.json | 2 +- 5 files changed, 16 insertions(+), 9 deletions(-) delete mode 100644 .chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md diff --git a/.chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md b/.chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md deleted file mode 100644 index 47f3be07419..00000000000 --- a/.chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -changeKind: fix -packages: - - "@typespec/spector" ---- - -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. 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/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/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 dcbae222e41..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",