CVE
CVE-2026-73416, CVE-2026-73627, CVE-2026-73626
CWE
CWE-178, CWE-180, CWE-602, CWE-636, CWE-863
Affected Surface
- PyPI package `jupyterlab` `>=4.1.0,<=4.5.9` and `>=4.6.0,<=4.6.1`, with `CVE-2026-73416` affecting `>=4.5.0` in the 4.5 line and `CVE-2026-73626` / `CVE-2026-73627` affecting the broader published ranges
- JupyterLab and Notebook v7+ deployments that expose the PyPI Extension Manager to authenticated users
- JupyterHub or managed notebook installations that rely on `blocked_extensions_uris`, `allowed_extensions_uris`, `lock_rules`, or `lock_all_plugins` as hardening controls
- Custom extensions or downstream integrations that import `PyPIExtensionManager` and call `install()` directly with user-influenced package names
The best new package-security story from the last three days is not another npm worm. It is a quieter PyPI-side trust-boundary failure in JupyterLab. Three related disclosures landed in NVD on 13 August 2026 and all of them sit in the same control plane: how a multi-user JupyterLab deployment decides which extensions an authenticated user is allowed to install, enable, or disable.
The important point is that these are not generic notebook bugs. They affect operator policy around the jupyterlab PyPI package itself:
CVE-2026-73416: PyPI blocklist checks compared a custom normalized name instead of the canonical package name thatpipactually resolves.CVE-2026-73627:POST /lab/api/pluginscould bypass two administrator lock mechanisms on the server side.CVE-2026-73626: a related defense-in-depth path inPyPIExtensionManager.install()forgot toawaitits own allowlist or blocklist check for direct callers.
Taken together, the August patch train is a reminder that package-governance features only work if the server enforces the same name resolution and lock semantics as the package installer beneath it.
Affected package and version scope
The affected PyPI package is jupyterlab.
| Finding | Affected versions | Fixed versions |
|---|---|---|
CVE-2026-73416 blocklist canonicalization bypass | >=4.5.0,<4.5.10 and >=4.6.0,<4.6.2 | 4.5.10, 4.6.2 |
CVE-2026-73627 plugin lock-rule bypass | >=4.1.0,<=4.5.9 and >=4.6.0,<=4.6.1 | 4.5.10, 4.6.2 |
CVE-2026-73626 missing await in direct-caller install path | <=4.5.9 and >=4.6.0,<=4.6.1 | 4.5.10, 4.6.2 |
This does not mean every Jupyter notebook deployment is equally exposed.
The highest-value targets are:
- shared JupyterHub and managed notebook platforms
- environments where operators intentionally restrict extension installation with
allowed_extensions_urisorblocked_extensions_uris - environments where plugin lock rules are part of product hardening, for example to keep upload, download, or UI-control plugins in a fixed state
- downstream code that imports
PyPIExtensionManagerdirectly instead of going through the stock HTTP handler
Why this belongs in application security coverage
JupyterLab extensions are not cosmetic browser themes. They can change server-exposed behavior, UI affordances, upload and download paths, and extension-provided code that runs inside the same deployment boundary the operator is trying to govern.
In practice, the policy path looks like this:
authenticated user
-> JupyterLab Extension Manager or /lab/api/*
-> JupyterLab policy checks
-> pip package-name resolution and extension install
-> extension code becomes part of the running environment
If the policy layer and the installer disagree about what package name means, or if the server trusts lock rules only in the client path, the deployment can fail open even when the operator explicitly configured controls.
CVE-2026-73416: blocklist checks normalized strings, but pip canonicalized package names
The cleanest bug in the set is CVE-2026-73416.
JupyterLab let operators configure package allowlists or blocklists through extension listings. The vulnerable path compared the requested install name against cached listing names with a custom normalization routine:
normalized = self._normalize_name(name)
normalized_cache = {self._normalize_name(k) for k in self._listings_cache}
That sounds harmless until you compare it with how PyPI names actually behave. pip and related tooling treat hyphens, underscores, dots, and case variants as equivalent under package-name canonicalization. So an operator might block jupyterlab-git, but a user could request a PyPI-equivalent spelling such as:
JupyterLab.Git
and still reach the same package.
The patch is short and makes the core problem obvious:
normalized = self._canonicalize_name(name)
normalized_cache = {self._canonicalize_name(k) for k in self._listings_cache}
In the PyPI-specific manager, _canonicalize_name() now delegates to packaging.utils.canonicalize_name():
def _canonicalize_name(self, name: str) -> str:
return canonicalize_name(name)
That is the right fix shape. JupyterLab stops comparing its own looser string form and instead compares the same canonical name family that PyPI tooling will resolve.
Why this mattered operationally
If an administrator relied on a blocklist to keep a risky or non-approved extension off a shared notebook platform, the control was only as strong as JupyterLab’s name matching. Before the patch, the policy engine and the package ecosystem disagreed about identity. The user only needed a spelling variant that still mapped to the same PyPI package.
This is one of the most common package-security failure modes:
policy validates pre-canonicalized input
-> installer canonicalizes input later
-> blocked package still resolves
CVE-2026-73627: /lab/api/plugins could bypass extension-level locks and lock_all
CVE-2026-73627 is the stronger server-side issue because it did not depend on package-name tricks. It affected the plugin management API directly.
The advisory says two server-side enforcement gaps let an authenticated user bypass administrator lock rules by sending direct requests to /lab/api/plugins. The patch shows both gaps.
First, extension-level locks were not applied to child plugin IDs. Before the fix, _find_locked() only treated an exact plugin ID as locked when the identifier contained a colon:
if ":" in plugin:
if plugin in self.options.lock_rules:
locked_subset.add(plugin)
After the fix, JupyterLab also checks the extension prefix before the colon:
if ":" in plugin:
extension = plugin.split(":")[0]
if plugin in self.options.lock_rules or extension in self.options.lock_rules:
locked_subset.add(plugin)
That matters because administrators can lock an entire extension by name, not only one leaf plugin. Before the patch, a lock on the extension could fail to cover plugin identifiers under that extension.
Second, the server-side handler path used the wrong option key for the global lock-all setting. The patched test setup changed:
"all_locked": False
to:
"lock_all": lock_all_plugins
That naming mismatch meant a deployment could believe “lock all plugins” was enabled while the handler path was not actually enforcing the intended option.
The result was an authenticated API-level bypass. A direct request such as:
POST /lab/api/plugins
Content-Type: application/json
{"cmd":"disable","plugin_name":"@jupyterlab/apputils-extension:sanitizer"}
could reach plugin state transitions that operators expected to be locked.
Why the plugin path matters
This is not just a settings bug. Plugin lock rules often backstop other security choices. If an operator locked a plugin to preserve download restrictions, upload restrictions, or a hardened UI state, an authenticated user could step around that intent by talking to the API directly instead of using the normal UI path.
That is why the advisory frames the issue as integrity and availability impact rather than a pure UX bug.
CVE-2026-73626: the direct-caller install path forgot to await its own policy check
The third bug, CVE-2026-73626, is narrower but worth including because it shows how close the whole extension-governance path was to failing open.
In the vulnerable PyPIExtensionManager.install() method, the code checked policy like this:
if not self.is_install_allowed(name, version):
return ActionResult(status="error", message="install is not allowed")
Because is_install_allowed() is async, that branch never awaited the coroutine. The fixed code is:
if not await self.is_install_allowed(name, version):
return ActionResult(status="error", message="install is not allowed")
Upstream is clear that stock JupyterLab was not directly exposed here because the normal HTTP API and Extension Manager UI already performed their own awaited check before calling install(). The bug mattered for custom extensions or downstream integrations that imported PyPIExtensionManager and trusted the public install() method to enforce allowlists or blocklists by itself.
Even with that narrower scope, it is a useful failure pattern to note:
public method appears to self-enforce policy
-> missing await turns the check into a truthy coroutine object
-> call path falls through
In other words, the defense-in-depth path existed in code, but not at runtime.
The July patch train became fresh August triage
One awkward detail here is timing. JupyterLab published the GitHub advisories and fixed releases on 21 July in 4.5.10 and 4.6.2, but the CVE records that make this easier for enterprise scanners to notice were published into NVD on 13 August.
That timing matters for defenders because a lot of package governance and image-review workflows key off CVE publication, not upstream release notes alone. If your notebook platform tracks NVD more closely than GitHub advisories, this may look like a new August issue even though the code fix was already out in late July.
What to check in your own deployments
Start with package version and configuration scope:
python3 -c "import jupyterlab; print(jupyterlab.__version__)"
rg -n \
"allowed_extensions_uris|blocked_extensions_uris|lock_rules|lock_all_plugins|extension_manager" \
/etc/jupyter "$HOME/.jupyter" .
Then identify whether the risky trust boundary exists:
Can authenticated users reach the PyPI Extension Manager?
Do operators depend on allowlists or blocklists for extension governance?
Do operators depend on plugin locks or lock_all for hardening?
Do any custom extensions call PyPIExtensionManager.install() directly?
If the answer to any of those is yes, upgrade to jupyterlab==4.6.2 or 4.5.10 immediately, and remember that Notebook v7+ consumers still need the jupyterlab package update because the affected logic lives there.
Why this is worth a dedicated article
The last few months have produced louder package stories: maintainer compromise, install-time malware, Bun-launched worms, and Linux post-exploitation chains. This JupyterLab cluster is different. It shows what happens when package governance logic is implemented one abstraction layer above the package manager but does not exactly match the lower layer’s semantics.
That mismatch showed up three ways:
- package names were validated before canonicalization
- plugin locks were enforced incompletely on the server
- one defense-in-depth install path did not actually await its own policy check
None of those bugs looks dramatic in isolation. Together they are a good case study in why package-governance features have to be threat-modeled like authentication or authorization code, not like convenience UI.
From research to remediation
Check whether this pattern exists in your codebase
Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.
References
- NVD: CVE-2026-73416
- NVD: CVE-2026-73627
- NVD: CVE-2026-73626
- GitHub Advisory: GHSA-89vp-jrxv-24w8
- GitHub Advisory: GHSA-h5v5-8746-g7mm
- GitHub Advisory: GHSA-whvh-wf3x-g77j
- JupyterLab release: v4.6.2
- JupyterLab release: v4.5.10
- JupyterLab PR #19184: security patches on main
- JupyterLab PR #19185: backport to 4.6.x
- JupyterLab PR #19186: backport to 4.5.x