{"repro_id":"REPRO-2026-00153","version":8,"title":"Jupyter Server: path traversal via faulty startswith() root containment check","repro_type":"security","status":"published","severity":"high","description":"Jupyter Server confines file access to a configured **root directory**. To\nenforce this, it resolves the requested path and checks that it is contained\nwithin the root by performing a plain string `startswith()` test against the\nconfigured root path.\n\n`startswith()` matches *string prefixes*, not *path-component boundaries*. A\nsibling directory whose name merely shares a prefix with the root therefore\npasses the check. If the root is `/srv/data`, then `/srv/data-secret` also\n\"starts with\" `/srv/data`, so a file under `/srv/data-secret` is incorrectly\ntreated as living inside the root.\n\nAn attacker who can reach the server's file/contents endpoints can use this to\nread files outside the configured root directory — any directory whose path\nstring begins with the root path.","root_cause":"# RCA Report: CVE-2026-35397\n\n## Summary\n\nCVE-2026-35397 is a path traversal vulnerability in `jupyter-server` caused by an improper root directory containment check. The `_get_os_path` method in `jupyter_server/services/contents/fileio.py` used a plain string `startswith()` comparison to verify that a requested file path lies within the configured root directory. Because `startswith()` matches string prefixes rather than path-component boundaries, a sibling directory whose name merely shares a prefix with the root directory (e.g., root=`/tmp/data`, sibling=`/tmp/datasecret`) would incorrectly pass the containment check when reached via a relative path traversal (e.g., `../datasecret/secret.txt`). When `ContentsManager.allow_hidden=True` is set (or when other `is_hidden` checks are bypassed), this allows an attacker to read files outside the configured root via the HTTP `/api/contents/` endpoint.\n\n## Impact\n\n- **Package**: `jupyter-server` (PyPI)\n- **Affected versions**: `<= 2.17.0`\n- **Fixed version**: `2.18.0`\n- **Risk level**: High (CVSS 7.6)\n- **Consequences**: Any client able to reach the Jupyter Server file/contents API can read arbitrary files located in directories whose absolute path strings start with the root directory path, when the sibling directory name shares a prefix with the root. This is a classic CWE-22 (Path Traversal) issue.\n\n## Root Cause\n\nThe vulnerable code was in `jupyter_server/services/contents/fileio.py`, method `FileManagerMixin._get_os_path()`:\n\n```python\nroot = os.path.abspath(self.root_dir)\nos_path = to_os_path(ApiPath(path), root)\nif not (os.path.abspath(os_path) + os.path.sep).startswith(root):\n    raise HTTPError(404, \"%s is outside root contents directory\" % path)\n```\n\nThe bug: `startswith(root)` matches any path whose string representation begins with `root`. If `root` is `/tmp/data`, then `/tmp/datasecret/secret.txt` also starts with `/tmp/data` (string-wise), so the check passes even though `datasecret` is a completely different directory outside the root.\n\n**Fix commit**: `2ee51eccf3ff2e27068cc0b7a39101eeedc4f665`\n**Fix**: Changed the check to `startswith(root + os.path.sep)`:\n\n```python\nif not (os.path.abspath(os_path) + os.path.sep).startswith(root + os.path.sep):\n    raise HTTPError(404, \"%s is outside root contents directory\" % path)\n```\n\nAppending `os.path.sep` forces the match to occur at a path-component boundary, so `/tmp/datasecret/...` no longer matches `/tmp/data/`.\n\n## Reproduction Steps\n\n1. Run `repro/reproduction_steps.sh`\n2. The script:\n   - Creates a virtualenv and installs `jupyter-server==2.17.0`\n   - Creates a root directory (`data/`) and a prefix-sharing sibling directory (`datasecret/`) with a secret file inside it\n   - Starts the Jupyter Server with `ContentsManager.allow_hidden=True` (this bypasses the `is_hidden` defense that would otherwise block the traversal before reaching `_get_os_path`)\n   - Issues an HTTP GET to `/api/contents/%2e%2e%2fdatasecret/secret.txt?content=1`\n   - Observes HTTP 200 with the secret file contents on the vulnerable version\n   - Repeats with `jupyter-server==2.18.0`\n   - Observes HTTP 404 on the fixed version\n\n**Expected evidence**:\n- Vulnerable (2.17.0): `HTTP 200` with JSON response body containing `\"content\": \"SECRET CONTENT\\n\"`\n- Fixed (2.18.0): `HTTP 404` with body `file or directory '/../datasecret/secret.txt' does not exist`\n\n## Evidence\n\nLog files produced by the reproduction script:\n- `logs/vulnerable.log` — Jupyter Server stdout/stderr for the 2.17.0 run\n- `logs/vulnerable_http_body.json` — HTTP response body for the exploit request\n- `logs/vulnerable_manifest.json` — runtime manifest capturing request URL, HTTP status, and file paths\n- `logs/fixed.log` — Jupyter Server stdout/stderr for the 2.18.0 run\n- `logs/fixed_http_body.json` — HTTP response body for the fixed version request\n- `logs/fixed_manifest.json` — runtime manifest for the fixed version\n\nKey excerpts from `logs/vulnerable_http_body.json`:\n```json\n{\"name\": \"secret.txt\", \"path\": \"../datasecret/secret.txt\", \"content\": \"SECRET CONTENT\\n\", ...}\n```\nHTTP status: `200`\n\nKey excerpts from `logs/fixed_http_body.json`:\n```\nfile or directory '/../datasecret/secret.txt' does not exist\n```\nHTTP status: `404`\n\n**Environment**:\n- Python 3.11.15\n- pip 24.0\n- Linux (x86_64)\n- Jupyter Server 2.17.0 (vulnerable) and 2.18.0 (fixed)\n\n## Recommendations / Next Steps\n\n1. **Upgrade immediately** to `jupyter-server>=2.18.0`.\n2. **Path containment checks should always use path-boundary-aware comparisons**, not plain string prefix matching. In Python, `pathlib.Path.is_relative_to()` or appending `os.path.sep` to both sides of a `startswith()` check are reliable patterns.\n3. **Regression test**: The fix commit already adds a pytest test (`test_path_traversal_when_sibling_dir_starts_with_root_dir`) that should be kept and run in CI.\n4. **Audit similar checks**: Search the codebase for other uses of `startswith()` on filesystem paths that may have the same flaw.\n\n## Additional Notes\n\n- **Idempotency confirmed**: The reproduction script was run twice consecutively and produced identical results (HTTP 200 on vulnerable, HTTP 404 on fixed).\n- **Edge case**: The HTTP `/api/contents/` endpoint is protected by an `is_hidden` check in `jupyter_core.paths` that uses `Path.is_relative_to()`. This check blocks the traversal BEFORE `_get_os_path` is reached when `allow_hidden=False` (the default). However, if a deployment sets `ContentsManager.allow_hidden=True` (e.g., to serve files in hidden directories), the `is_hidden` gate is bypassed and the buggy `_get_os_path` check becomes the only remaining defense — which fails due to the `startswith()` bug. This makes the vulnerability exploitable in real deployments that enable hidden file serving.\n- The `/files/` handler has the same `is_hidden` check, so it is similarly protected by default but becomes vulnerable when `allow_hidden=True`.\n","ghsa_id":"GHSA-5789-5fc7-67v3","cve_id":"CVE-2026-35397","cwe_id":"CWE-22 (Path Traversal)","source_url":"https://github.com/jupyter-server/jupyter_server","package":{"name":"jupyter-server","ecosystem":"pip","affected_versions":"<= 2.17.0","fixed_version":"2.18.0"},"reproduced_at":"2026-05-22T18:35:04.436633+00:00","duration_secs":1514.5624961853027,"tool_calls":312,"turns":271,"handoffs":3,"total_cost_usd":2.72993303,"agent_costs":{"repro":0.8615181400000002,"support":0.04509203,"vuln_variant":1.82332286},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":0.8615181400000002},"support":{"accounts/fireworks/models/kimi-k2p6":0.04509203},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":1.82332286}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T18:35:06.754081+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":5937,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":3737,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":7614,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":4921,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":3228,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":645,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":3499,"category":"ticket"},{"path":"repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1441,"category":"other"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":1161,"category":"other"},{"path":"vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":961,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":3798,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2882,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2343,"category":"other"},{"path":"vuln_variant/source_identity.json","filename":"source_identity.json","size":765,"category":"other"},{"path":"logs/variant_vuln_restore.log","filename":"variant_vuln_restore.log","size":926,"category":"log"},{"path":"logs/fixed.log.http_body","filename":"fixed.log.http_body","size":90,"category":"other"},{"path":"logs/vulnerable_v2_body.json","filename":"vulnerable_v2_body.json","size":0,"category":"other"},{"path":"logs/fixed_manifest.json","filename":"fixed_manifest.json","size":230,"category":"other"},{"path":"logs/v2c_body.json","filename":"v2c_body.json","size":0,"category":"other"},{"path":"logs/v2_vuln_fresh2.json","filename":"v2_vuln_fresh2.json","size":68,"category":"other"},{"path":"logs/fixed.log.server","filename":"fixed.log.server","size":1222,"category":"other"},{"path":"logs/variant_fixed_fresh2.log","filename":"variant_fixed_fresh2.log","size":1387,"category":"log"},{"path":"logs/v4_body.json","filename":"v4_body.json","size":63,"category":"other"},{"path":"logs/fixed_v1_body.json","filename":"fixed_v1_body.json","size":91,"category":"other"},{"path":"logs/variant_fixed_server2.log","filename":"variant_fixed_server2.log","size":1205,"category":"log"},{"path":"logs/vulnerable_v3_body.json","filename":"vulnerable_v3_body.json","size":63,"category":"other"},{"path":"logs/v2_vuln_fresh.json","filename":"v2_vuln_fresh.json","size":91,"category":"other"},{"path":"logs/vulnerable_v1_body.json","filename":"vulnerable_v1_body.json","size":68,"category":"other"},{"path":"logs/variant_vuln_server2.log","filename":"variant_vuln_server2.log","size":1112,"category":"log"},{"path":"logs/v2_fixed_restore.json","filename":"v2_fixed_restore.json","size":91,"category":"other"},{"path":"logs/vulnerable.log.server","filename":"vulnerable.log.server","size":1222,"category":"other"},{"path":"logs/v2_body.json","filename":"v2_body.json","size":68,"category":"other"},{"path":"logs/variant_fixed_restore.log","filename":"variant_fixed_restore.log","size":1846,"category":"log"},{"path":"logs/v1_body.json","filename":"v1_body.json","size":63,"category":"other"},{"path":"logs/fixed_http_body.json","filename":"fixed_http_body.json","size":60,"category":"other"},{"path":"logs/v2_fixed_body.json","filename":"v2_fixed_body.json","size":68,"category":"other"},{"path":"logs/fixed_v2_body.json","filename":"fixed_v2_body.json","size":79,"category":"other"},{"path":"logs/v2c_fixed_restore.json","filename":"v2c_fixed_restore.json","size":79,"category":"other"},{"path":"logs/v3_body.json","filename":"v3_body.json","size":63,"category":"other"},{"path":"logs/v2b_fixed_body.json","filename":"v2b_fixed_body.json","size":70,"category":"other"},{"path":"logs/vulnerable.log.http_body","filename":"vulnerable.log.http_body","size":90,"category":"other"},{"path":"logs/v2_vuln_restore.json","filename":"v2_vuln_restore.json","size":68,"category":"other"},{"path":"logs/vulnerable.log.direct","filename":"vulnerable.log.direct","size":144,"category":"other"},{"path":"logs/v2_fixed_fresh2.json","filename":"v2_fixed_fresh2.json","size":91,"category":"other"},{"path":"logs/fixed.log.direct","filename":"fixed.log.direct","size":111,"category":"other"},{"path":"logs/v2b_fixed2_body.json","filename":"v2b_fixed2_body.json","size":70,"category":"other"},{"path":"logs/variant_fixed_fresh.log","filename":"variant_fixed_fresh.log","size":1019,"category":"log"},{"path":"logs/variant_vuln_fresh.log","filename":"variant_vuln_fresh.log","size":1840,"category":"log"},{"path":"logs/vulnerable_http_body.json","filename":"vulnerable_http_body.json","size":307,"category":"other"},{"path":"logs/variant_fixed_server.log","filename":"variant_fixed_server.log","size":1112,"category":"log"},{"path":"logs/vulnerable_manifest.json","filename":"vulnerable_manifest.json","size":240,"category":"other"},{"path":"logs/fixed_v3_body.json","filename":"fixed_v3_body.json","size":79,"category":"other"},{"path":"logs/vulnerable.log","filename":"vulnerable.log","size":5166,"category":"log"},{"path":"logs/variant_vuln_server.log","filename":"variant_vuln_server.log","size":8376,"category":"log"},{"path":"logs/fixed.log","filename":"fixed.log","size":2366,"category":"log"},{"path":"logs/v2b_body.json","filename":"v2b_body.json","size":70,"category":"other"},{"path":"logs/v2_fixed2_body.json","filename":"v2_fixed2_body.json","size":68,"category":"other"},{"path":"logs/v1_vuln_fresh.json","filename":"v1_vuln_fresh.json","size":90,"category":"other"},{"path":"logs/variant_vuln_fresh2.log","filename":"variant_vuln_fresh2.log","size":5120,"category":"log"},{"path":"logs/v2_fixed_fresh.json","filename":"v2_fixed_fresh.json","size":68,"category":"other"},{"path":"logs/v1_vuln_fresh2.json","filename":"v1_vuln_fresh2.json","size":63,"category":"other"},{"path":"logs/v2c_vuln_restore.json","filename":"v2c_vuln_restore.json","size":0,"category":"other"}]}