# Root Cause Analysis — CVE-2026-72572: xmysql `/download` Path Traversal

## Summary

xmysql (o1lab/xmysql, a zero-config REST API generator for MySQL/MariaDB) exposes an
unauthenticated `GET /download` endpoint whose handler `downloadFile(req, res)` in
`lib/xapi.js` builds a filesystem path with `path.join(process.cwd(), req.query.name)`
and passes it directly to Express `res.download(file)`. The `name` query parameter is
never validated, normalized against, or confined to a base directory, so an
unauthenticated remote attacker can supply `../` traversal sequences and read any file
readable by the xmysql process.

## Impact

- **Package/component:** `o1lab/xmysql` (npm `xmysql`), route handler
  `lib/xapi.js:downloadFile` (lines 424–427 at commit `8c6b00e`).
- **Affected versions:** all versions. The project was renamed to NocoDB and the
  repository archived; no patched upstream version of xmysql exists.
- **Risk level and consequences:** High. Unauthenticated arbitrary file read from the
  server filesystem (database credentials, `/etc/passwd`, TLS keys, application source,
  cloud metadata files on disk, etc.). The route is registered by default whenever the
  MySQL host is localhost (`program.dynamic = 1` in `lib/util/cmd.helper.js`) and
  `readOnly` is false (the default), so default deployments are exposed.

## Impact Parity

- **Disclosed/claimed maximum impact:** unauthenticated remote arbitrary file disclosure
  (`info_leak` via `api_remote`).
- **Reproduced impact from this run:** unauthenticated remote arbitrary file read —
  `/etc/passwd` returned byte-identical over HTTP 200, and a per-run planted secret file
  (`/tmp/pruva_xmysql_secret.txt` with a unique token) was recovered verbatim through the
  same endpoint.
- **Parity:** `full`.
- **Not demonstrated:** nothing claimed beyond file disclosure; no further impact was
  claimed or required.

## Root Cause

In `lib/xapi.js`:

```js
downloadFile(req, res) {
  let file = path.join(process.cwd(), req.query.name);
  res.download(file);
}
```

`req.query.name` is fully attacker-controlled. `path.join` resolves `..` segments
lexically, so a value such as `../../../../../../etc/passwd` escapes the process working
directory entirely (excess `..` above `/` collapse to `/`). The result is handed to
`res.download()`, which happily streams any file the process can read. There is:

1. no authentication middleware on the route (registered as
   `this.app.get("/download", this.downloadFile.bind(this))` inside the
   `dynamic === 1 && !readOnly` block at `lib/xapi.js:322–340`),
2. no allowlist/confined upload-download directory, and
3. no rejection of `..` or absolute-path components.

- **Fix commit:** none known; xmysql is unmaintained/archived (renamed to NocoDB).

## Reproduction Steps

1. `bundle/repro/reproduction_steps.sh` (idempotent; run twice consecutively, both exit 0).
2. The script:
   - reuses the prepared project cache (`/pruva/project-cache/repo`) or clones
     `https://github.com/o1lab/xmysql`, then pins commit
     `8c6b00ee22860230975e43ab705d015d2235e308` (v0.6.0, latest master) and asserts the
     vulnerable line is present in `lib/xapi.js`;
   - installs and starts MariaDB, creates schema `reprodb` with a table, and gives the
     TCP account a native password (dual-mode SQL runner keeps it idempotent);
   - installs node dependencies with `npm install --ignore-scripts` (the declared but
     unused `sleep@6.1.0` native module fails to build on modern Node and is irrelevant);
   - starts the real product: `node bin/index.js -h 127.0.0.1 -u root -p rootpass -d reprodb -n 3000`
     and waits for `/_health`;
   - plants a unique-token secret file outside the app working directory;
   - sends the unauthenticated attacker request
     `GET /download?name=../../../../../../../etc/passwd` and
     `GET /download?name=../../../../../../../tmp/pruva_xmysql_secret.txt`;
   - runs a benign control request (`name=definitely_not_here.txt`, observed HTTP 400);
   - verifies byte-identity with `/etc/passwd` and token recovery, then writes
     `bundle/repro/runtime_manifest.json` and exits 0 on success.
3. **Expected evidence:** HTTP 200 responses with `Content-Disposition: attachment` for
   both traversal requests; downloaded `/etc/passwd` byte-identical to the real file;
   planted secret token recovered.

## Evidence

- `bundle/logs/reproduction_steps.log` — full session log of both runs.
- `bundle/logs/xmysql_service.log` — real xmysql startup banner ("REST APIs Generated: 135").
- `bundle/logs/health_response.json` — `/_health` response proving service liveness.
- `bundle/logs/download_passwd_headers.txt` + `bundle/logs/downloaded_passwd.txt` —
  HTTP 200 and byte-identical `/etc/passwd` (`diff` clean, `^root:` present).
- `bundle/logs/download_secret_headers.txt` + `bundle/logs/downloaded_secret.txt` —
  unique per-run token (e.g. `PRUVA_XMYSQL_SECRET_1786374155853866884`) recovered via
  traversal.
- `bundle/logs/download_benign_status.txt` — benign control returned HTTP 400.
- Environment: Linux x86_64, Node.js v24.18.0, MariaDB 11.8.6, xmysql v0.6.0
  @ `8c6b00ee22860230975e43ab705d015d2235e308` (target digest
  `d7544f1501df408c3c5353b430cef5842b463250b64824974436575afb948112`).

## Recommendations / Next Steps

- **Fix approach:** drop the endpoint or confine downloads to a dedicated storage
  directory: resolve `path.resolve(STORAGE_DIR, name)` and reject any result that does
  not start with `STORAGE_DIR + path.sep`; additionally reject `..`/absolute inputs and
  require authentication/authorization on the route.
- **Upgrade guidance:** xmysql is unmaintained; migrate to NocoDB or another maintained
  API layer. Until then, run with `--readOnly` (or a non-localhost DB host) so the
  `dynamic` block (upload/download routes) is never registered, or front the service
  with a proxy that blocks `/download`.
- **Testing recommendations:** regression test that `GET /download?name=../...` returns
  4xx and that downloads are confined to the storage directory.

## Additional Notes

- **Idempotency:** the script was executed twice consecutively in the same workspace and
  both runs exited 0; DB setup is dual-mode (unix-socket root first run, TCP password on
  re-runs) and service startup kills any previous instance.
- **Negative control:** no patched upstream version exists (all versions affected,
  repository archived), so a fixed-version differential is not applicable; a benign
  in-cwd control request is included instead (HTTP 400 for a nonexistent file).
- **Edge cases:** the route only exists when `dynamic === 1 && !readOnly`; dynamic
  defaults to 1 whenever the MySQL host is localhost/127.0.0.1/::1, which is the common
  deployment. The server binds to `localhost` (may resolve to `::1`); the script probes
  both `127.0.0.1` and `localhost`.
