Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/spector/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/spector/docs/using-spector.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/spector/package.json
Original file line number Diff line number Diff line change
@@ -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": {
".": {
Expand Down
11 changes: 9 additions & 2 deletions packages/spector/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
});

Expand Down
7 changes: 7 additions & 0 deletions packages/typespec-vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
9 changes: 6 additions & 3 deletions packages/typespec-vscode/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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."
}
}
}
Expand Down
23 changes: 23 additions & 0 deletions packages/typespec-vscode/src/task-command.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
32 changes: 19 additions & 13 deletions packages/typespec-vscode/src/task-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(
{
Expand All @@ -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<vscode.Task[]> {
Expand All @@ -117,8 +123,8 @@ async function createBuiltInTasks(targetPath: string): Promise<vscode.Task[]> {
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);
});
Expand Down
56 changes: 56 additions & 0 deletions packages/typespec-vscode/test/unit/task-command.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
});
});
Loading