Jupyter Server: path traversal via faulty startswith() root containment check
What's the vulnerability?
Jupyter Server confines file access to a configured root directory. To
enforce this, it resolves the requested path and checks that it is contained
within the root by performing a plain string startswith() test against the
configured root path.
startswith() matches string prefixes, not path-component boundaries. A
sibling directory whose name merely shares a prefix with the root therefore
passes the check. If the root is /srv/data, then /srv/data-secret also
"starts with" /srv/data, so a file under /srv/data-secret is incorrectly
treated as living inside the root.
An attacker who can reach the server's file/contents endpoints can use this to read files outside the configured root directory — any directory whose path string begins with the root path.
Root Cause Analysis
# 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`.
Verify with pruva-verify
Run the Pruva CLI to automatically fetch and execute the reproduction script.
pruva-verify REPRO-2026-00153 pruva-verify GHSA-5789-5fc7-67v3 pruva-verify CVE-2026-35397 curl -fsSL https://pruva.dev/install.sh | sh Or Run Manually
Download the script
curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00153/artifacts/reproduction_steps.sh Make executable
chmod +x reproduction_steps.sh Run the script
./reproduction_steps.sh How Pruva Reproduced This
Watch the AI agent's step-by-step process.
Loading session...
Artifacts
No artifacts available