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`.
One Command

Verify with pruva-verify

Run the Pruva CLI to automatically fetch and execute the reproduction script.

pruva-verify REPRO-2026-00153
or pruva-verify GHSA-5789-5fc7-67v3
or pruva-verify CVE-2026-35397
Install: curl -fsSL https://pruva.dev/install.sh | sh

Or Run Manually

1

Download the script

curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00153/artifacts/reproduction_steps.sh
2

Make executable

chmod +x reproduction_steps.sh
3

Run the script

./reproduction_steps.sh
Run in a VM, container, or disposable environment. This exploits a real vulnerability.

How Pruva Reproduced This

Watch the AI agent's step-by-step process.

Loading session...

Artifacts

No artifacts available