CVE-2026-35397: Verified Repro With Script Download
CVE-2026-35397: Jupyter Server: path traversal via faulty startswith root containment check
CVE-2026-35397 is verified against jupyter-server · pip. Affected versions: <= 2.17.0. Fixed in 2.18.0. Vulnerability class: Path Traversal. This high reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00153.
What Is CVE-2026-35397?
CVE-2026-35397 is a high-severity path traversal (CWE-22) in Jupyter Server caused by a faulty root-directory containment check, letting a client read files outside the configured root directory via the contents API. Pruva reproduced it (reproduction REPRO-2026-00153).
CVE-2026-35397 Severity & CVSS Score
CVE-2026-35397 is rated high severity, with a CVSS base score of 8.8 out of 10.
High — serious impact or readily exploitable. Prioritize remediation.
Affected jupyter-server Versions
jupyter-server · pip versions <= 2.17.0 are affected.
How to Reproduce CVE-2026-35397
pruva-verify REPRO-2026-00153 curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00153/artifacts/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-35397
Reproduced by Pruva's autonomous agents — 312 tool calls over 25 min. Full root-cause analysis and the complete transcript are below.
How the agent worked
Root Cause and Exploit Chain for 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():
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):
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
- Run
repro/reproduction_steps.sh - 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 theis_hiddendefense 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
- Creates a virtualenv and installs
Expected evidence:
- Vulnerable (2.17.0):
HTTP 200with JSON response body containing"content": "SECRET CONTENT\n" - Fixed (2.18.0):
HTTP 404with bodyfile 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 runlogs/vulnerable_http_body.json— HTTP response body for the exploit requestlogs/vulnerable_manifest.json— runtime manifest capturing request URL, HTTP status, and file pathslogs/fixed.log— Jupyter Server stdout/stderr for the 2.18.0 runlogs/fixed_http_body.json— HTTP response body for the fixed version requestlogs/fixed_manifest.json— runtime manifest for the fixed version
Key excerpts from logs/vulnerable_http_body.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
- Upgrade immediately to
jupyter-server>=2.18.0. - Path containment checks should always use path-boundary-aware comparisons, not plain string prefix matching. In Python,
pathlib.Path.is_relative_to()or appendingos.path.septo both sides of astartswith()check are reliable patterns. - 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. - 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 anis_hiddencheck injupyter_core.pathsthat usesPath.is_relative_to(). This check blocks the traversal BEFORE_get_os_pathis reached whenallow_hidden=False(the default). However, if a deployment setsContentsManager.allow_hidden=True(e.g., to serve files in hidden directories), theis_hiddengate is bypassed and the buggy_get_os_pathcheck becomes the only remaining defense — which fails due to thestartswith()bug. This makes the vulnerability exploitable in real deployments that enable hidden file serving. - The
/files/handler has the sameis_hiddencheck, so it is similarly protected by default but becomes vulnerable whenallow_hidden=True.
CVE-2026-35397 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
Artifacts and Evidence for CVE-2026-35397
Scripts, logs, diffs, and output captured during the reproduction.
No artifacts available
How to Fix CVE-2026-35397
Upgrade jupyter-server · pip to 2.18.0 or later.
FAQ: CVE-2026-35397
How does the Jupyter Server path-traversal work?
Which Jupyter Server versions are affected by CVE-2026-35397, and where is it fixed?
How severe is CVE-2026-35397?
How can I reproduce CVE-2026-35397?
References for CVE-2026-35397
Authoritative sources for CVE-2026-35397 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.