{"repro_id":"REPRO-2026-00324","version":6,"title":"Unauthenticated path traversal in xmysql `/download` allows arbitrary file read via unsanitized `req.query.name`.","repro_type":"security","status":"published","severity":"high","cvss_score":7.5,"description":"A path traversal vulnerability exists in **o1lab/xmysql** that allows an unauthenticated remote attacker to read arbitrary files from the server via the `/download` endpoint.","root_cause":"# Root Cause Analysis — CVE-2026-72572: xmysql `/download` Path Traversal\n\n## Summary\n\nxmysql (o1lab/xmysql, a zero-config REST API generator for MySQL/MariaDB) exposes an\nunauthenticated `GET /download` endpoint whose handler `downloadFile(req, res)` in\n`lib/xapi.js` builds a filesystem path with `path.join(process.cwd(), req.query.name)`\nand passes it directly to Express `res.download(file)`. The `name` query parameter is\nnever validated, normalized against, or confined to a base directory, so an\nunauthenticated remote attacker can supply `../` traversal sequences and read any file\nreadable by the xmysql process.\n\n## Impact\n\n- **Package/component:** `o1lab/xmysql` (npm `xmysql`), route handler\n  `lib/xapi.js:downloadFile` (lines 424–427 at commit `8c6b00e`).\n- **Affected versions:** all versions. The project was renamed to NocoDB and the\n  repository archived; no patched upstream version of xmysql exists.\n- **Risk level and consequences:** High. Unauthenticated arbitrary file read from the\n  server filesystem (database credentials, `/etc/passwd`, TLS keys, application source,\n  cloud metadata files on disk, etc.). The route is registered by default whenever the\n  MySQL host is localhost (`program.dynamic = 1` in `lib/util/cmd.helper.js`) and\n  `readOnly` is false (the default), so default deployments are exposed.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** unauthenticated remote arbitrary file disclosure\n  (`info_leak` via `api_remote`).\n- **Reproduced impact from this run:** unauthenticated remote arbitrary file read —\n  `/etc/passwd` returned byte-identical over HTTP 200, and a per-run planted secret file\n  (`/tmp/pruva_xmysql_secret.txt` with a unique token) was recovered verbatim through the\n  same endpoint.\n- **Parity:** `full`.\n- **Not demonstrated:** nothing claimed beyond file disclosure; no further impact was\n  claimed or required.\n\n## Root Cause\n\nIn `lib/xapi.js`:\n\n```js\ndownloadFile(req, res) {\n  let file = path.join(process.cwd(), req.query.name);\n  res.download(file);\n}\n```\n\n`req.query.name` is fully attacker-controlled. `path.join` resolves `..` segments\nlexically, so a value such as `../../../../../../etc/passwd` escapes the process working\ndirectory entirely (excess `..` above `/` collapse to `/`). The result is handed to\n`res.download()`, which happily streams any file the process can read. There is:\n\n1. no authentication middleware on the route (registered as\n   `this.app.get(\"/download\", this.downloadFile.bind(this))` inside the\n   `dynamic === 1 && !readOnly` block at `lib/xapi.js:322–340`),\n2. no allowlist/confined upload-download directory, and\n3. no rejection of `..` or absolute-path components.\n\n- **Fix commit:** none known; xmysql is unmaintained/archived (renamed to NocoDB).\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (idempotent; run twice consecutively, both exit 0).\n2. The script:\n   - reuses the prepared project cache (`/pruva/project-cache/repo`) or clones\n     `https://github.com/o1lab/xmysql`, then pins commit\n     `8c6b00ee22860230975e43ab705d015d2235e308` (v0.6.0, latest master) and asserts the\n     vulnerable line is present in `lib/xapi.js`;\n   - installs and starts MariaDB, creates schema `reprodb` with a table, and gives the\n     TCP account a native password (dual-mode SQL runner keeps it idempotent);\n   - installs node dependencies with `npm install --ignore-scripts` (the declared but\n     unused `sleep@6.1.0` native module fails to build on modern Node and is irrelevant);\n   - starts the real product: `node bin/index.js -h 127.0.0.1 -u root -p rootpass -d reprodb -n 3000`\n     and waits for `/_health`;\n   - plants a unique-token secret file outside the app working directory;\n   - sends the unauthenticated attacker request\n     `GET /download?name=../../../../../../../etc/passwd` and\n     `GET /download?name=../../../../../../../tmp/pruva_xmysql_secret.txt`;\n   - runs a benign control request (`name=definitely_not_here.txt`, observed HTTP 400);\n   - verifies byte-identity with `/etc/passwd` and token recovery, then writes\n     `bundle/repro/runtime_manifest.json` and exits 0 on success.\n3. **Expected evidence:** HTTP 200 responses with `Content-Disposition: attachment` for\n   both traversal requests; downloaded `/etc/passwd` byte-identical to the real file;\n   planted secret token recovered.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full session log of both runs.\n- `bundle/logs/xmysql_service.log` — real xmysql startup banner (\"REST APIs Generated: 135\").\n- `bundle/logs/health_response.json` — `/_health` response proving service liveness.\n- `bundle/logs/download_passwd_headers.txt` + `bundle/logs/downloaded_passwd.txt` —\n  HTTP 200 and byte-identical `/etc/passwd` (`diff` clean, `^root:` present).\n- `bundle/logs/download_secret_headers.txt` + `bundle/logs/downloaded_secret.txt` —\n  unique per-run token (e.g. `PRUVA_XMYSQL_SECRET_1786374155853866884`) recovered via\n  traversal.\n- `bundle/logs/download_benign_status.txt` — benign control returned HTTP 400.\n- Environment: Linux x86_64, Node.js v24.18.0, MariaDB 11.8.6, xmysql v0.6.0\n  @ `8c6b00ee22860230975e43ab705d015d2235e308` (target digest\n  `d7544f1501df408c3c5353b430cef5842b463250b64824974436575afb948112`).\n\n## Recommendations / Next Steps\n\n- **Fix approach:** drop the endpoint or confine downloads to a dedicated storage\n  directory: resolve `path.resolve(STORAGE_DIR, name)` and reject any result that does\n  not start with `STORAGE_DIR + path.sep`; additionally reject `..`/absolute inputs and\n  require authentication/authorization on the route.\n- **Upgrade guidance:** xmysql is unmaintained; migrate to NocoDB or another maintained\n  API layer. Until then, run with `--readOnly` (or a non-localhost DB host) so the\n  `dynamic` block (upload/download routes) is never registered, or front the service\n  with a proxy that blocks `/download`.\n- **Testing recommendations:** regression test that `GET /download?name=../...` returns\n  4xx and that downloads are confined to the storage directory.\n\n## Additional Notes\n\n- **Idempotency:** the script was executed twice consecutively in the same workspace and\n  both runs exited 0; DB setup is dual-mode (unix-socket root first run, TCP password on\n  re-runs) and service startup kills any previous instance.\n- **Negative control:** no patched upstream version exists (all versions affected,\n  repository archived), so a fixed-version differential is not applicable; a benign\n  in-cwd control request is included instead (HTTP 400 for a nonexistent file).\n- **Edge cases:** the route only exists when `dynamic === 1 && !readOnly`; dynamic\n  defaults to 1 whenever the MySQL host is localhost/127.0.0.1/::1, which is the common\n  deployment. The server binds to `localhost` (may resolve to `::1`); the script probes\n  both `127.0.0.1` and `localhost`.\n","cve_id":"CVE-2026-72572","cwe_id":"CWE-22 (Path Traversal)","source_url":"https://nvd.nist.gov/vuln/detail/CVE-2026-72572","package":{"name":"xmysql","ecosystem":"npm / JavaScript (Node.js, Express)","affected_versions":"all versions (project is deprecated/superseded by nocodb; no patched version exists)"},"reproduced_at":"2026-08-23T15:38:18.998946+00:00","duration_secs":731.0,"tool_calls":126,"handoffs":2,"total_cost_usd":2.007625,"agent_costs":{"claim_matcher":0.012138,"judge":0.325117,"learning_policy":0.010052,"repro":1.096466,"support":0.051048,"vuln_variant":0.512804},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.012138},"judge":{"gpt-5.5":0.275541,"gpt-5.5-2026-04-23":0.049576},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.010052},"repro":{"accounts/fireworks/models/kimi-k3":1.096466},"support":{"accounts/fireworks/models/kimi-k3":0.051048},"vuln_variant":{"accounts/fireworks/models/kimi-k3":0.512804}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-08-23T15:38:19.567971+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":6862,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":9502,"category":"reproduction_script"},{"path":"bundle/logs/download_benign_status.txt","filename":"download_benign_status.txt","size":3,"category":"other"},{"path":"bundle/logs/download_passwd_headers.txt","filename":"download_passwd_headers.txt","size":401,"category":"other"},{"path":"bundle/logs/download_secret_headers.txt","filename":"download_secret_headers.txt","size":416,"category":"other"},{"path":"bundle/logs/downloaded_passwd.txt","filename":"downloaded_passwd.txt","size":1262,"category":"other"},{"path":"bundle/logs/downloaded_secret.txt","filename":"downloaded_secret.txt","size":40,"category":"other"},{"path":"bundle/logs/health_response.json","filename":"health_response.json","size":51,"category":"other"},{"path":"bundle/logs/xmysql_service.log","filename":"xmysql_service.log","size":1093,"category":"log"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1108,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":743,"category":"other"}]}