GHSA-g6g7-pvmx-m74p: 9router: Missing Authorization and OS Command Injection
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.Details1. Middleware matcher (`src/proxy.js:3-15`)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`)`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`)The byte stream sent to the stdin of the `sudo -S sh` child process is: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:The `sudoPassword` value from the request — interpreted as the first shell command.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.PoCThe 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****Step 2 — start the target****Step 3 — exploit (one unauthenticated POST)****Step 4 — verify**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 |
CVSS v4.0: 9.2