# REPRO-2026-00153: Jupyter Server: path traversal via faulty startswith() root containment check ## Summary Status: published Severity: high Type: security Confidence: Unknown ## Identifiers REPRO ID: REPRO-2026-00153 GHSA: GHSA-5789-5fc7-67v3 CVE: CVE-2026-35397 ## Package Name: jupyter-server Ecosystem: pip Affected: <= 2.17.0 Fixed: 2.18.0 ## Root Cause # RCA Report: CVE-2026-35397 ## Summary CVE-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. ## Impact - **Package**: `jupyter-server` (PyPI) - **Affected versions**: `<= 2.17.0` - **Fixed version**: `2.18.0` - **Risk level**: High (CVSS 7.6) - **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. ## Root Cause The vulnerable code was in `jupyter_server/services/contents/fileio.py`, method `FileManagerMixin._get_os_path()`: ```python root = os.path.abspath(self.root_dir) os_path = to_os_path(ApiPath(path), root) if not (os.path.abspath(os_path) + os.path.sep).startswith(root): raise HTTPError(404, "%s is outside root contents directory" % path) ``` The 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. **Fix commit**: `2ee51eccf3ff2e27068cc0b7a39101eeedc4f665` **Fix**: Changed the check to `startswith(root + os.path.sep)`: ```python if not (os.path.abspath(os_path) + os.path.sep).startswith(root + os.path.sep): raise HTTPError(404, "%s is outside root contents directory" % path) ``` Appending `os.path.sep` forces the match to occur at a path-component boundary, so `/tmp/datasecret/...` no longer matches `/tmp/data/`. ## Reproduction Steps 1. Run `repro/reproduction_steps.sh` 2. The script: - Creates a virtualenv and installs `jupyter-server==2.17.0` - Creates a root directory (`data/`) and a prefix-sharing sibling directory (`datasecret/`) with a secret file inside it - 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`) - Issues an HTTP GET to `/api/contents/%2e%2e%2fdatasecret/secret.txt?content=1` - Observes HTTP 200 with the secret file contents on the vulnerable version - Repeats with `jupyter-server==2.18.0` - Observes HTTP 404 on the fixed version **Expected evidence**: - Vulnerable (2.17.0): `HTTP 200` with JSON response body containing `"content": "SECRET CONTENT\n"` - Fixed (2.18.0): `HTTP 404` with body `file or directory '/../datasecret/secret.txt' does not exist` ## Evidence Log files produced by the reproduction script: - `logs/vulnerable.log` — Jupyter Server stdout/stderr for the 2.17.0 run - `logs/vulnerable_http_body.json` — HTTP response body for the exploit request - `logs/vulnerable_manifest.json` — runtime manifest capturing request URL, HTTP status, and file paths - `logs/fixed.log` — Jupyter Server stdout/stderr for the 2.18.0 run - `logs/fixed_http_body.json` — HTTP response body for the fixed version request - `logs/fixed_manifest.json` — runtime manifest for the fixed version Key excerpts from `logs/vulnerable_http_body.json`: ```json {"name": "secret.txt", "path": "../datasecret/secret.txt", "content": "SECRET CONTENT\n", ...} ``` HTTP status: `200` Key excerpts from `logs/fixed_http_body.json`: ``` file or directory '/../datasecret/secret.txt' does not exist ``` HTTP status: `404` **Environment**: - Python 3.11.15 - pip 24.0 - Linux (x86_64) - Jupyter Server 2.17.0 (vulnerable) and 2.18.0 (fixed) ## Recommendations / Next Steps 1. **Upgrade immediately** to `jupyter-server>=2.18.0`. 2. **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. 3. **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. 4. **Audit similar checks**: Search the codebase for other uses of `startswith()` on filesystem paths that may have the same flaw. ## Additional Notes - **Idempotency confirmed**: The reproduction script was run twice consecutively and produced identical results (HTTP 200 on vulnerable, HTTP 404 on fixed). - **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. - The `/files/` handler has the same `is_hidden` check, so it is similarly protected by default but becomes vulnerable when `allow_hidden=True`. ## Reproduction Details Reproduced: 2026-05-22T18:35:04.436Z Duration: 1515 seconds Tool calls: 312 Turns: 271 Handoffs: 3 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00153 pruva-verify GHSA-5789-5fc7-67v3 pruva-verify CVE-2026-35397 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00153&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00153/artifacts/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - GitHub Advisory: https://github.com/advisories/GHSA-5789-5fc7-67v3 - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-35397 - Source: https://github.com/jupyter-server/jupyter_server ## Artifacts - repro/rca_report.md (analysis, 5937 bytes) - repro/reproduction_steps.sh (reproduction_script, 3737 bytes) - vuln_variant/rca_report.md (analysis, 7614 bytes) - vuln_variant/reproduction_steps.sh (reproduction_script, 4921 bytes) - bundle/context.json (other, 3228 bytes) - bundle/metadata.json (other, 645 bytes) - bundle/ticket.md (ticket, 3499 bytes) - repro/runtime_manifest.json (other, 1441 bytes) - repro/validation_verdict.json (other, 1161 bytes) - vuln_variant/root_cause_equivalence.json (other, 961 bytes) - vuln_variant/patch_analysis.md (documentation, 3798 bytes) - vuln_variant/variant_manifest.json (other, 2882 bytes) - vuln_variant/validation_verdict.json (other, 2343 bytes) - vuln_variant/source_identity.json (other, 765 bytes) - logs/variant_vuln_restore.log (log, 926 bytes) - logs/fixed.log.http_body (other, 90 bytes) - logs/vulnerable_v2_body.json (other, 0 bytes) - logs/fixed_manifest.json (other, 230 bytes) - logs/v2c_body.json (other, 0 bytes) - logs/v2_vuln_fresh2.json (other, 68 bytes) - logs/fixed.log.server (other, 1222 bytes) - logs/variant_fixed_fresh2.log (log, 1387 bytes) - logs/v4_body.json (other, 63 bytes) - logs/fixed_v1_body.json (other, 91 bytes) - logs/variant_fixed_server2.log (log, 1205 bytes) - logs/vulnerable_v3_body.json (other, 63 bytes) - logs/v2_vuln_fresh.json (other, 91 bytes) - logs/vulnerable_v1_body.json (other, 68 bytes) - logs/variant_vuln_server2.log (log, 1112 bytes) - logs/v2_fixed_restore.json (other, 91 bytes) - logs/vulnerable.log.server (other, 1222 bytes) - logs/v2_body.json (other, 68 bytes) - logs/variant_fixed_restore.log (log, 1846 bytes) - logs/v1_body.json (other, 63 bytes) - logs/fixed_http_body.json (other, 60 bytes) - logs/v2_fixed_body.json (other, 68 bytes) - logs/fixed_v2_body.json (other, 79 bytes) - logs/v2c_fixed_restore.json (other, 79 bytes) - logs/v3_body.json (other, 63 bytes) - logs/v2b_fixed_body.json (other, 70 bytes) - logs/vulnerable.log.http_body (other, 90 bytes) - logs/v2_vuln_restore.json (other, 68 bytes) - logs/vulnerable.log.direct (other, 144 bytes) - logs/v2_fixed_fresh2.json (other, 91 bytes) - logs/fixed.log.direct (other, 111 bytes) - logs/v2b_fixed2_body.json (other, 70 bytes) - logs/variant_fixed_fresh.log (log, 1019 bytes) - logs/variant_vuln_fresh.log (log, 1840 bytes) - logs/vulnerable_http_body.json (other, 307 bytes) - logs/variant_fixed_server.log (log, 1112 bytes) - logs/vulnerable_manifest.json (other, 240 bytes) - logs/fixed_v3_body.json (other, 79 bytes) - logs/vulnerable.log (log, 5166 bytes) - logs/variant_vuln_server.log (log, 8376 bytes) - logs/fixed.log (log, 2366 bytes) - logs/v2b_body.json (other, 70 bytes) - logs/v2_fixed2_body.json (other, 68 bytes) - logs/v1_vuln_fresh.json (other, 90 bytes) - logs/variant_vuln_fresh2.log (log, 5120 bytes) - logs/v2_fixed_fresh.json (other, 68 bytes) - logs/v1_vuln_fresh2.json (other, 63 bytes) - logs/v2c_vuln_restore.json (other, 0 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00153 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00153/artifacts/repro/reproduction_steps.sh - Web: https://pruva.dev/r/REPRO-2026-00153 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev