The Notebook You Trust Could Be Reading Your Secrets
Think Jupyter notebooks are safe? This breakdown reveals how nbconvert path traversal flaws allow file reads and writes without ever executing a single cell.
Table of Contents
Jupyter notebooks have a habit of feeling safe.
They look like documents. They get passed around like reports. They sit in repos next to Markdown files and screenshots. So when a notebook is fed into a converter, it is easy to treat that step as harmless plumbing.
That assumption doesn’t always hold up.
While working on a VDP target that processed notebooks as part of its workflow - specifically converting .ipynb files to HTML - I started digging into how that conversion actually worked. That led me into nbconvert, and eventually to two path traversal issues that break the usual expectations.
One allows a crafted notebook to read files from the system handling the conversion. The other allows it to write files outside the intended output directory.
Neither requires notebook code execution.
These issues sit entirely in how file paths are handled during conversion. By default, nbconvert converts notebooks; it does not execute them unless explicitly configured to do so. A notebook can remain “unexecuted” in the usual sense and still interact with the filesystem through the converter.
File read via embedded images (CVE-2026-39378)
The first issue sits in nbconvert’s Markdown handling.
When the HTML exporter is configured with:
embed_images=True
It reads image files referenced in Markdown cells and embeds them into the generated HTML as base64 data URIs. That feature is useful - it gives you a single self-contained output file, which is exactly what people want for reports and previews.
The problem is in the IPythonRenderer class (nbconvert/filters/markdown_mistune.py). When a Markdown cell contains an image reference, it flows through _embed_image_or_attachment and into _src_to_base64, which reads the file from disk:
def _src_to_base64(self, src):
src_path = os.path.join(self.path, src)
if not os.path.exists(src_path):
return None
with open(src_path, "rb") as fobj:
mime_type, _ = mimetypes.guess_type(src_path)
base64_data = base64.b64encode(fobj.read())
base64_str = base64_data.replace(b"\n", b"").decode("ascii")
return f"data:{mime_type};base64,{base64_str}"
The variable src comes directly from the Markdown image reference in the notebook - entirely attacker-controlled. It gets joined onto self.path (the notebook's directory) with no check that the resulting path stays within that directory.
So a notebook can do this:

If that notebook is converted with image embedding enabled, nbconvert reads the target file and drops the bytes into the HTML output as a base64-encoded data URI. Anyone with access to the output can decode it and recover the contents.
This doesn’t bypass permissions - the converter only reads what the running user can access. But in practice, that can still include things like environment files, config, or other local data that was never meant to be exposed.
File write via attachment names (CVE-2026-39377)
The second issue lives in attachment extraction, specifically when exporting notebooks to Markdown format.
Notebooks can carry attachments inside their JSON. During Markdown conversion, nbconvert extracts these attachments and writes them to disk alongside the output.
The filename used for this comes directly from notebook data. The vulnerable code lives in ExtractAttachmentsPreprocessor (nbconvert/preprocessors/extractattachments.py), which iterates over each cell's attachments and writes them to the output resources:
def preprocess_cell(self, cell, resources, index):
if "attachments" in cell:
for fname in cell.attachments:
for mimetype in cell.attachments[fname]:
data = cell.attachments[fname][mimetype].encode("utf-8")
decoded = b64decode(data)
break
# fname comes directly from the notebook JSON - no sanitisation
new_filename = os.path.join(self.path_name, fname)
resources[self.resources_item_key][new_filename] = decoded
The key line is os.path.join(self.path_name, fname). The variable fname is the attachment key from the notebook's cell data - entirely attacker-controlled. Because there is no check that the resulting path stays within the output directory, a traversal payload like ../../../../tmp/evil.txt passes through unchecked. The FilesWriter then writes the decoded content to that path on disk.
A malicious notebook can include an attachment entry like this:
{ "attachments": { "../../../../tmp/example.txt": { "text/plain": "ZXhhbXBsZSBkYXRh" } } }
If that path is used as-is, the extracted attachment can end up outside the intended output directory.
Again, permissions still apply. This is not “write anywhere on the system”. But depending on how the conversion environment is set up, writing outside the expected directory can still interfere with build artefacts, temporary files, or other parts of the system.
These issues are most relevant anywhere notebooks are handled automatically.
Think notebook preview services, submission systems, CI jobs that render documentation, or internal platforms that accept notebooks and process them in the background.
In those setups, nobody needs to open the notebook in Jupyter and click “Run All”. The act of converting it is enough.
This is less about notebooks themselves, and more about how the tooling around them treats their contents. The format carries attacker-controlled paths, and the converter trusts them without validation.
On a tightly controlled service, the impact may be limited. On a less constrained one, it can be enough to expose files or place data somewhere it shouldn’t be.
If you run systems that convert notebooks:
-
treat notebooks as untrusted input
-
avoid embed_images unless you genuinely need it
-
keep conversion isolated (containers, sandboxes)
-
keep filesystem access narrow
-
run the converter with as little privilege as possible
If you are just converting notebooks locally, the advice is simpler: be cautious with notebooks from sources you don’t trust - especially when generating standalone HTML or exporting to Markdown with attachments.
At a glance, these bugs look like straightforward file read and write primitives. In practice, that is often enough.
A read primitive can expose configuration, credentials, or environment details. A write primitive can be used to place files in locations that were never meant to be user-controlled. When both are available, they can be chained together to understand a system and then modify it in a meaningful way.
Even a standalone write issue can be impactful in the right environment. If the conversion process runs on an appliance or web application, guessing common paths - for example a web root on a PHP-based system - can allow attacker-controlled content to be written somewhere it may later be executed.
This is why isolation matters.
Any process that handles user-controlled input should assume that input is hostile. Running conversion in a sandbox, limiting filesystem access, and avoiding unnecessary privileges significantly reduces what an attacker can do with issues like this.
The vulnerabilities themselves are fixable, and fixes have already been released. The more important lesson is architectural: treat file-processing pipelines the same way you would treat any other exposed attack surface.
| 14 January 2026 | Arbitrary file read vulnerability discovered |
| 15 January 2026 | Arbitrary file write vulnerability discovered |
| 15 January 2026 | Reported to Jupyter security team |
| 9 February 2026 | Reports acknowledged by Jupyter |
| 22 February 2026 | Patches submitted to Jupyter for review |
| 4 March 2026 | Patches reviewed by Jupyter, adjustments made following feedback |
| 26 March 2026 | Update requestd from Jupyter regarding patches being released |
| 1 April 2026 | Jupyter state that patches will be released within the week |
| 6 April 2026 | Patches delayed, but to be released iminentely, CVEs assigned |
| 8 April 2026 | Version 7.17.1 released with patches, advisories to be released in 7 days |
| 20 April 2027 | Advisories published |
These issues affected nbconvert >= 6.5 and < 7.17.1. The verison published which resolves the issues is 7.17.1