Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: [email protected], [email protected]
Control: affects -1 + src:nbconvert
User: [email protected]
Usertags: pu
* CVE-2026-39377: Arbitrary File Write via Path Traversal in
Cell Attachment Filenames (Closes: #1134889)
* CVE-2026-39378: Arbitrary File Read via Path Traversal in
HTMLExporter Image Embedding (Closes: #1134890)
diffstat for nbconvert-7.16.6 nbconvert-7.16.6
changelog | 10 +
patches/0001-Merge-commit-from-fork.patch | 163 ++++++++++++++++++++++++++++++
patches/0002-Merge-commit-from-fork.patch | 83 +++++++++++++++
patches/series | 2
4 files changed, 258 insertions(+)
diff -Nru nbconvert-7.16.6/debian/changelog nbconvert-7.16.6/debian/changelog
--- nbconvert-7.16.6/debian/changelog 2025-02-09 17:45:36.000000000 +0200
+++ nbconvert-7.16.6/debian/changelog 2026-06-20 16:39:11.000000000 +0300
@@ -1,3 +1,13 @@
+nbconvert (7.16.6-1+deb13u1) trixie; urgency=medium
+
+ * Non-maintainer upload.
+ * CVE-2026-39377: Arbitrary File Write via Path Traversal in
+ Cell Attachment Filenames (Closes: #1134889)
+ * CVE-2026-39378: Arbitrary File Read via Path Traversal in
+ HTMLExporter Image Embedding (Closes: #1134890)
+
+ -- Adrian Bunk <[email protected]> Sat, 20 Jun 2026 16:39:11 +0300
+
nbconvert (7.16.6-1) unstable; urgency=medium
* Team upload.
diff -Nru nbconvert-7.16.6/debian/patches/0001-Merge-commit-from-fork.patch
nbconvert-7.16.6/debian/patches/0001-Merge-commit-from-fork.patch
--- nbconvert-7.16.6/debian/patches/0001-Merge-commit-from-fork.patch
1970-01-01 02:00:00.000000000 +0200
+++ nbconvert-7.16.6/debian/patches/0001-Merge-commit-from-fork.patch
2026-06-20 16:39:11.000000000 +0300
@@ -0,0 +1,163 @@
+From 573260af525a7b01a3c188159a15031ccd54e307 Mon Sep 17 00:00:00 2001
+From: James Hooker <[email protected]>
+Date: Wed, 8 Apr 2026 01:09:09 +0100
+Subject: Merge commit from fork
+
+Co-authored-by: g0blin <[email protected]>
+---
+ nbconvert/preprocessors/extractattachments.py | 25 ++++-
+ .../preprocessors/test_extractattachments.py | 91 ++++++++++++++++++-
+ 2 files changed, 114 insertions(+), 2 deletions(-)
+
+diff --git a/nbconvert/preprocessors/extractattachments.py
b/nbconvert/preprocessors/extractattachments.py
+index 740e1960..096e7651 100644
+--- a/nbconvert/preprocessors/extractattachments.py
++++ b/nbconvert/preprocessors/extractattachments.py
+@@ -82,6 +82,22 @@ class ExtractAttachmentsPreprocessor(Preprocessor):
+ for fname in cell.attachments:
+ self.log.debug("Encountered attachment %s", fname)
+
++ # Sanitize: use only the basename to prevent path traversal
++ safe_fname = os.path.basename(fname)
++ if not safe_fname:
++ self.log.warning(
++ "Attachment filename '%s' is invalid (empty
basename), skipping",
++ fname,
++ )
++ continue
++ if safe_fname != fname:
++ self.log.warning(
++ "Attachment filename '%s' contained path components, "
++ "using basename '%s'",
++ fname,
++ safe_fname,
++ )
++
+ # Add file for writer
+
+ # Right now I don't know of a situation where there would be
multiple
+@@ -94,7 +110,14 @@ class ExtractAttachmentsPreprocessor(Preprocessor):
+ break
+
+ # FilesWriter wants path to be in attachment filename here
+- new_filename = os.path.join(self.path_name, fname)
++ new_filename = os.path.join(self.path_name, safe_fname)
++ if new_filename in resources[self.resources_item_key]:
++ self.log.warning(
++ "Attachment filename '%s' (from '%s') overwrites a
previous "
++ "attachment with the same name",
++ safe_fname,
++ fname,
++ )
+ resources[self.resources_item_key][new_filename] = decoded
+
+ # Edit the reference to the attachment
+diff --git a/tests/preprocessors/test_extractattachments.py
b/tests/preprocessors/test_extractattachments.py
+index 739b6919..a2cbd1e2 100644
+--- a/tests/preprocessors/test_extractattachments.py
++++ b/tests/preprocessors/test_extractattachments.py
+@@ -4,7 +4,9 @@
+ # Distributed under the terms of the Modified BSD License.
+
+ import os
+-from base64 import b64decode
++from base64 import b64decode, b64encode
++
++from nbformat import v4 as nbformat
+
+ from nbconvert.preprocessors.extractattachments import
ExtractAttachmentsPreprocessor
+
+@@ -86,3 +88,90 @@ class TestExtractAttachments(PreprocessorTestsBase):
+ src = nb.cells[-1].source
+ # This shouldn't change on Windows
+ self.assertEqual(src, "")
++
++ def test_attachment_path_traversal_sanitised(self):
++ """Test that path traversal in attachment filenames is sanitised.
++
++ Crafted attachment filenames containing '../' sequences must not
escape
++ the output directory. The preprocessor should strip path components,
++ store the file under its basename only, and update the cell source
++ reference accordingly.
++ """
++ malicious_fname =
"../../../../../../../tmp/nbconvert_traversal/evil.php"
++ malicious_content = b"<?php\n// I should not be here"
++ b64_content = b64encode(malicious_content).decode("utf-8")
++ attachments = {malicious_fname: {"text/plain": b64_content}}
++ cell = nbformat.new_markdown_cell(
++ source=f"",
++ attachments=attachments,
++ )
++ nb = nbformat.new_notebook(cells=[cell])
++ res = self.build_resources()
++ preprocessor = self.build_preprocessor()
++ nb, res = preprocessor(nb, res)
++
++ # The output key must be the basename only - no traversal components
++ self.assertIn("evil.php", res["outputs"])
++ self.assertEqual(res["outputs"]["evil.php"], malicious_content)
++ for key in res["outputs"]:
++ self.assertNotIn("..", key)
++ self.assertFalse(os.path.isabs(key))
++
++ # Cell source must reference the safe, flattened filename
++ self.assertEqual(nb.cells[0].source, "")
++
++ def test_attachment_absolute_path_sanitised(self):
++ """Test that absolute paths in attachment filenames are sanitised.
++
++ An absolute path like '/tmp/absolute/test1' would cause os.path.join
++ to discard the output directory prefix entirely, writing to the
++ absolute location. The preprocessor must strip the path and use
++ only the basename.
++ """
++ abs_fname = "/tmp/absolute/test1"
++ content = b"absolute path write test"
++ b64_content = b64encode(content).decode("utf-8")
++ attachments = {abs_fname: {"text/plain": b64_content}}
++ cell = nbformat.new_markdown_cell(
++ source=f"",
++ attachments=attachments,
++ )
++ nb = nbformat.new_notebook(cells=[cell])
++ res = self.build_resources()
++ preprocessor = self.build_preprocessor()
++ nb, res = preprocessor(nb, res)
++
++ # The output key must be the basename only - not the absolute path
++ self.assertIn("test1", res["outputs"])
++ self.assertNotIn("/tmp/absolute/test1", res["outputs"])
++ self.assertEqual(res["outputs"]["test1"], content)
++ for key in res["outputs"]:
++ self.assertFalse(os.path.isabs(key))
++
++ # Cell source must reference the safe, flattened filename
++ self.assertEqual(nb.cells[0].source, "")
++
++ def test_attachment_empty_basename_skipped(self):
++ """Test that filenames resolving to an empty basename are skipped.
++
++ A filename like '../../../tmp/' has an empty os.path.basename(),
++ which would cause downstream errors if not caught. The preprocessor
++ should skip these attachments entirely and log a warning.
++ """
++ bad_fname = "../../../tmp/"
++ b64_content = b64encode(b"should be skipped").decode("utf-8")
++ attachments = {bad_fname: {"text/plain": b64_content}}
++ cell = nbformat.new_markdown_cell(
++ source=f"",
++ attachments=attachments,
++ )
++ nb = nbformat.new_notebook(cells=[cell])
++ res = self.build_resources()
++ preprocessor = self.build_preprocessor()
++ nb, res = preprocessor(nb, res)
++
++ # No outputs should be created for empty basename
++ self.assertEqual(len(res["outputs"]), 0)
++
++ # Cell source should remain unchanged (attachment ref not rewritten)
++ self.assertEqual(nb.cells[0].source, f"")
+--
+2.47.3
+
diff -Nru nbconvert-7.16.6/debian/patches/0002-Merge-commit-from-fork.patch
nbconvert-7.16.6/debian/patches/0002-Merge-commit-from-fork.patch
--- nbconvert-7.16.6/debian/patches/0002-Merge-commit-from-fork.patch
1970-01-01 02:00:00.000000000 +0200
+++ nbconvert-7.16.6/debian/patches/0002-Merge-commit-from-fork.patch
2026-06-20 16:39:11.000000000 +0300
@@ -0,0 +1,83 @@
+From de92f54b0575ca9489661591481afa8a6a4f7ee5 Mon Sep 17 00:00:00 2001
+From: James Hooker <[email protected]>
+Date: Wed, 8 Apr 2026 01:09:43 +0100
+Subject: Merge commit from fork
+
+* Prevent path traversal when embedding images
+
+* Switch from realpath to abspath to avoid symlink abuse
+
+---------
+
+Co-authored-by: g0blin <[email protected]>
+---
+ nbconvert/filters/markdown_mistune.py | 5 +++++
+ tests/exporters/test_html.py | 28 +++++++++++++++++++++++++++
+ 2 files changed, 33 insertions(+)
+
+diff --git a/nbconvert/filters/markdown_mistune.py
b/nbconvert/filters/markdown_mistune.py
+index 67a6aa8f..80385a7f 100644
+--- a/nbconvert/filters/markdown_mistune.py
++++ b/nbconvert/filters/markdown_mistune.py
+@@ -435,6 +435,11 @@ class IPythonRenderer(HTMLRenderer):
+ """
+ src_path = os.path.join(self.path, src)
+
++ resolved = os.path.abspath(src_path)
++ allowed_base = os.path.abspath(self.path)
++ if not resolved.startswith(allowed_base + os.sep) and resolved !=
allowed_base:
++ return None
++
+ if not os.path.exists(src_path):
+ return None
+
+diff --git a/tests/exporters/test_html.py b/tests/exporters/test_html.py
+index f1bfb69b..5d1577ed 100644
+--- a/tests/exporters/test_html.py
++++ b/tests/exporters/test_html.py
+@@ -3,7 +3,10 @@
+ # Copyright (c) IPython Development Team.
+ # Distributed under the terms of the Modified BSD License.
+
++import base64
++import os
+ import re
++from tempfile import TemporaryDirectory
+
+ import pytest
+ from nbformat import v4
+@@ -264,6 +267,31 @@ class TestHTMLExporter(ExportersTestsBase):
+
+ assert '<html lang="en">' in output
+
++ def test_embed_images_path_traversal_blocked(self):
++ """Path traversal in image src should be blocked when
embed_images=True"""
++ with TemporaryDirectory() as parent_dir:
++ # Create a secret file that an attacker would try to exfiltrate
++ secret_content = b"SUPERSECRETCONTENT"
++ with open(os.path.join(parent_dir, "secret.txt"), "wb") as f:
++ f.write(secret_content)
++
++ # Create a temp subdirectory to serve as the notebook's working
path
++ with TemporaryDirectory(dir=parent_dir) as notebook_dir:
++ # Build a notebook with path traversal image references
++ nb = v4.new_notebook()
++
nb.cells.append(v4.new_markdown_cell(""))
++ nb.cells.append(v4.new_markdown_cell('<img
src="../secret.txt" alt="exfil"/>'))
++
++ exporter = HTMLExporter()
++ exporter.embed_images = True
++ html, _ = exporter.from_notebook_node(
++ nb, resources={"metadata": {"path": notebook_dir}}
++ )
++
++ # The secret content must NOT appear as base64 in the output
++ target_b64 = base64.b64encode(secret_content).decode()
++ self.assertNotIn(target_b64, html)
++
+
+ @pytest.mark.parametrize(
+ ("lexer_options"),
+--
+2.47.3
+
diff -Nru nbconvert-7.16.6/debian/patches/series
nbconvert-7.16.6/debian/patches/series
--- nbconvert-7.16.6/debian/patches/series 2025-02-09 17:45:36.000000000
+0200
+++ nbconvert-7.16.6/debian/patches/series 2026-06-20 16:39:11.000000000
+0300
@@ -5,3 +5,5 @@
set-nbsphinx_allow_errors-in-sphinx-conf.patch
dont-use-intersphinx-during-build.patch
skip-test_default_config-due-to-jupyter-core-4.11.2.patch
+0001-Merge-commit-from-fork.patch
+0002-Merge-commit-from-fork.patch