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, "![image.png](notebook1_custom/image.png)")
++
++    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"![exploit](attachment:{malicious_fname})",
++            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, "![exploit](evil.php)")
++
++    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"![file](attachment:{abs_fname})",
++            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, "![file](test1)")
++
++    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"![x](attachment:{bad_fname})",
++            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"![x](attachment:{bad_fname})")
+-- 
+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("![exfil](../secret.txt)"))
++                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

Reply via email to