Skip to content

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.

REPRO-2026-00153 jupyter-server · pip Path Traversal May 22, 2026 CVE entry ↗ .txt
Severity
HIGH
CVSS
8.8
Reproduced in
25m 14s
Tool calls
312
Spend
$2.73
01 · Overview

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).

02 · Severity & CVSS

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 threat level
8.8 / 10 CVSS base
03 · Affected Versions

Affected jupyter-server Versions

jupyter-server · pip versions <= 2.17.0 are affected.

How to Reproduce CVE-2026-35397

$ pruva-verify REPRO-2026-00153
or curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00153/artifacts/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

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 1,132 events · 312 tool calls · 25 min
25 minDuration
312Tool calls
271Reasoning steps
1,132Events
3Dead-ends
Agent activity over 25 min
Support
26
Repro
509
Variant
592
0:0025:14

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

  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:

{"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.

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.

Event 1/40
0:000:50
0:00
session startedaccounts/fireworks/models/kimi-k2p6 · cve-2026-35397 · cve-2026
0:02
0:04
0:05
web search
0:09
0:10
web search
0:11
0:13
0:14
web search
0:18
0:26
0:46
0:46
extract_facts
no facts extracted
0:48
0:48
0:48
supportrepro
0:49
0:49
0:49
0:49
0:50

Artifacts and Evidence for CVE-2026-35397

Scripts, logs, diffs, and output captured during the reproduction.

No artifacts available

08 · How to Fix

How to Fix CVE-2026-35397

Upgrade jupyter-server · pip to 2.18.0 or later.

Coming soon

Step-by-step mitigation and hardening guidance for CVE-2026-35397 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: CVE-2026-35397

How does the Jupyter Server path-traversal work?

An attacker reaches the HTTP /api/contents/ endpoint with a relative path traversal such as ../datasecret/secret.txt; because startswith() matches string prefixes rather than path segments, the resolved path under the sibling datasecret directory is treated as inside the root and the file is returned, especially when allow_hidden=True or other is_hidden checks are bypassed.

Which Jupyter Server versions are affected by CVE-2026-35397, and where is it fixed?

Jupyter Server <= 2.17.0 is affected; it is fixed in 2.18.0.

How severe is CVE-2026-35397?

It is rated high severity (CVSS 7.6) - any client able to reach the Jupyter Server contents API can read arbitrary files in directories whose absolute path string happens to start with the configured root directory's path.

How can I reproduce CVE-2026-35397?

Download the verified script from this page and run it in an isolated environment against Jupyter Server <=2.17.0; it configures a root directory, creates a sibling directory with a matching string prefix, and requests a file in that sibling via /api/contents/ with a ../ traversal path to show the vulnerable server returning it, then confirms 2.18.0 rejects it.
11 · References

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.