Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
python-click (8.1.6-1deepin1) unstable; urgency=medium

[ deepin-ci-robot ]
* fix(cve): CVE-2026-7246

[ lichenggang ]
* fix: add missing tempfile import in _termui_impl.py to resolve
NameError in _tempfilepager

-- deepin-ci-robot <packages@deepin.org> Tue, 21 Jul 2026 18:59:13 +0800

python-click (8.1.6-1) unstable; urgency=medium

* Team upload
Expand Down
282 changes: 282 additions & 0 deletions debian/patches/CVE-2026-7246.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
Description: CVE-2026-7246 - 安全修复
Author: Kevin Deldycke <kevin@deldycke.com>
Origin: https://github.com/pallets/click/commit/b96c2601af4e01341b4d2c0db494ebee4aef8f42
Bug: https://nvd.nist.gov/vuln/detail/CVE-2026-7246
Last-Update: 2026-03-04
---

diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py
index f744657..dc794e3 100644
--- a/src/click/_termui_impl.py
+++ b/src/click/_termui_impl.py
@@ -6,6 +6,10 @@ placed in this module and only imported as needed.
import contextlib
import math
import os
+import shlex
+import shutil
+import subprocess
+import tempfile
import sys
import time
import typing as t
@@ -358,7 +361,20 @@ class ProgressBar(t.Generic[V]):


def pager(generator: t.Iterable[str], color: t.Optional[bool] = None) -> None:
- """Decide what method to use for paging through text."""
+ """Decide what method to use for paging through text.
+
+ The ``PAGER`` environment variable is split into an ``argv`` list with
+ :func:`shlex.split` in its default POSIX mode so that quotes are
+ stripped from tokens and quoted Windows paths are preserved.
+
+ .. note::
+ ``posix=False`` was considered but rejected
+ because it retains quote characters in tokens. The
+ :func:`shlex.quote` approach was also reverted.
+
+ .. seealso::
+ :issue:`1026`, :pr:`1477` and :pr:`2775`.
+ """
stdout = _default_text_stdout()

# There are no standard streams attached to write to. For example,
@@ -368,17 +384,26 @@ def pager(generator: t.Iterable[str], color: t.Optional[bool] = None) -> None:

if not isatty(sys.stdin) or not isatty(stdout):
return _nullpager(stdout, generator, color)
- pager_cmd = (os.environ.get("PAGER", None) or "").strip()
- if pager_cmd:
+
+ # Split and normalize the pager command into parts.
+ pager_cmd_parts = shlex.split(os.environ.get("PAGER", ""))
+ if pager_cmd_parts:
if WIN:
- return _tempfilepager(generator, pager_cmd, color)
- return _pipepager(generator, pager_cmd, color)
+ if _tempfilepager(generator, pager_cmd_parts, color):
+ return
+ else:
+ if _pipepager(generator, pager_cmd_parts, color):
+ return
+
if os.environ.get("TERM") in ("dumb", "emacs"):
return _nullpager(stdout, generator, color)
+
if WIN or sys.platform.startswith("os2"):
- return _tempfilepager(generator, "more <", color)
- if hasattr(os, "system") and os.system("(less) 2>/dev/null") == 0:
- return _pipepager(generator, "less", color)
+ if _tempfilepager(generator, ["more", "<"], color):
+ return
+ elif hasattr(os, "system") and os.system("(less) 2>/dev/null") == 0:
+ if _pipepager(generator, ["less"], color):
+ return

import tempfile

@@ -386,23 +411,40 @@ def pager(generator: t.Iterable[str], color: t.Optional[bool] = None) -> None:
os.close(fd)
try:
if hasattr(os, "system") and os.system(f'more "{filename}"') == 0:
- return _pipepager(generator, "more", color)
+ if _pipepager(generator, ["more"], color):
+ return
return _nullpager(stdout, generator, color)
finally:
os.unlink(filename)


-def _pipepager(generator: t.Iterable[str], cmd: str, color: t.Optional[bool]) -> None:
- """Page through text by feeding it to another program. Invoking a
- pager through this might support colors.
- """
- import subprocess
+def _pipepager(
+ generator: t.Iterable[str], cmd_parts: list[str], color: t.Optional[bool]
+) -> bool:
+ """Page through text by feeding it to another program.
+
+ Invokes the pager via :class:`subprocess.Popen` with an ``argv`` list
+ produced by :func:`shlex.split`. The command is resolved to an absolute
+ path with :func:`shutil.which` as recommended by the
+ :mod:`subprocess` docs for Windows compatibility.

+ Invoking a pager through this might support colors: if piping to
+ ``less`` and the user hasn't decided on colors, ``LESS=-R`` is set
+ automatically.
+
+ Returns ``True`` if the command was found and executed, ``False``
+ otherwise so another pager can be attempted.
+
+ .. seealso::
+ :pr:`2775` improved error handling: :exc:`BrokenPipeError` is
+ caught specifically, generator exceptions terminate the pager, and
+ ``stdin.close()`` is always called in a ``finally`` block.
+ """
env = dict(os.environ)

# If we're piping to less we might support colors under the
# condition that
- cmd_detail = cmd.rsplit("/", 1)[-1].split()
+ cmd_detail = cmd_parts[-1].rsplit("/", 1)[-1].split()
if color is None and cmd_detail[0] == "less":
less_flags = f"{os.environ.get('LESS', '')}{' '.join(cmd_detail[1:])}"
if not less_flags:
@@ -411,7 +453,15 @@ def _pipepager(generator: t.Iterable[str], cmd: str, color: t.Optional[bool]) ->
elif "r" in less_flags or "R" in less_flags:
color = True

- c = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, env=env)
+ cmd_path = shutil.which(cmd_parts[0])
+ if cmd_path is None:
+ return False
+
+ c = subprocess.Popen(
+ [cmd_path] + cmd_parts[1:],
+ stdin=subprocess.PIPE,
+ env=env,
+ )
stdin = t.cast(t.BinaryIO, c.stdin)
encoding = get_best_encoding(stdin)
try:
@@ -420,7 +470,7 @@ def _pipepager(generator: t.Iterable[str], cmd: str, color: t.Optional[bool]) ->
text = strip_ansi(text)

stdin.write(text.encode(encoding, "replace"))
- except (OSError, KeyboardInterrupt):
+ except (OSError, KeyboardInterrupt, BrokenPipeError):
pass
else:
stdin.close()
@@ -441,13 +491,22 @@ def _pipepager(generator: t.Iterable[str], cmd: str, color: t.Optional[bool]) ->
else:
break

+ return True
+

def _tempfilepager(
- generator: t.Iterable[str], cmd: str, color: t.Optional[bool]
-) -> None:
- """Page through text by invoking a program on a temporary file."""
- import tempfile
+ generator: t.Iterable[str], cmd_parts: list[str], color: t.Optional[bool]
+) -> bool:
+ """Page through text by invoking a program on a temporary file.
+
+ Used as the primary pager strategy on Windows (where piping to
+ ``more`` adds spurious ``\\r\\n``), and as a fallback on other
+ platforms. The command is resolved to an absolute path with
+ :func:`shutil.which`.

+ Returns ``True`` if the command was found and executed, ``False``
+ otherwise so another pager can be attempted.
+ """
fd, filename = tempfile.mkstemp()
# TODO: This never terminates if the passed generator never terminates.
text = "".join(generator)
@@ -457,7 +516,11 @@ def _tempfilepager(
with open_stream(filename, "wb")[0] as f:
f.write(text.encode(encoding))
try:
- os.system(f'{cmd} "{filename}"')
+ cmd_path = shutil.which(cmd_parts[0])
+ if cmd_path is not None:
+ subprocess.Popen([cmd_path] + cmd_parts[1:] + [filename]).wait()
+ return True
+ return False
finally:
os.close(fd)
os.unlink(filename)
@@ -501,7 +564,15 @@ class Editor:
return "vi"

def edit_file(self, filename: str) -> None:
- import subprocess
+ """Open a file in the user's editor.
+
+ The editor command is split into an ``argv`` list with
+ :func:`shlex.split` in POSIX mode; see :func:`pager` for rationale.
+
+ .. seealso::
+ :issue:`1026` and :pr:`1477`.
+ """
+ import shlex

editor = self.get_editor()
environ: t.Optional[t.Dict[str, str]] = None
@@ -511,7 +582,10 @@ class Editor:
environ.update(self.env)

try:
- c = subprocess.Popen(f'{editor} "{filename}"', env=environ, shell=True)
+ c = subprocess.Popen(
+ shlex.split(editor) + [filename],
+ env=environ,
+ )
exit_code = c.wait()
if exit_code != 0:
raise ClickException(
diff --git a/tests/test_termui.py b/tests/test_termui.py
index 7cfa939..24b0acb 100644
--- a/tests/test_termui.py
+++ b/tests/test_termui.py
@@ -447,3 +447,56 @@ def test_confirmation_prompt(runner, prompt, input, default, expect):

if prompt == "Confirm Password":
assert "Confirm Password: " in result.output
+
+
+# Tests for CVE-2026-7246: Shell injection prevention in editor/pager commands
+from unittest.mock import patch, MagicMock
+
+
+def test_editor_uses_shlex_split():
+ """Verify that Editor.edit_file uses shlex.split instead of shell=True."""
+ from click._termui_impl import Editor
+
+ with patch("subprocess.Popen") as mock_popen:
+ mock_popen.return_value.wait.return_value = 0
+ editor = Editor(editor="myeditor --wait --flag")
+ editor.edit_file("test.txt")
+
+ mock_popen.assert_called_once()
+ call_args = mock_popen.call_args
+ # Verify shell is not True
+ assert call_args[1].get("shell") is not True
+ # Verify args is a list, not a string
+ args = call_args[1].get("args") or call_args[0][0]
+ assert isinstance(args, list)
+ assert args == ["myeditor", "--wait", "--flag", "test.txt"]
+
+
+def test_editor_with_shell_metacharacters():
+ """Verify that shell metacharacters in filenames are properly escaped."""
+ from click._termui_impl import Editor
+
+ with patch("subprocess.Popen") as mock_popen:
+ mock_popen.return_value.wait.return_value = 0
+ editor = Editor(editor="vi")
+ editor.edit_file('file"; rm -rf / ; echo "')
+
+ mock_popen.assert_called_once()
+ call_args = mock_popen.call_args
+ args = call_args[1].get("args") or call_args[0][0]
+ # The filename should be a separate argument, not interpreted by shell
+ assert isinstance(args, list)
+ assert 'file"; rm -rf / ; echo "' in args
+
+
+def test_pager_uses_list_args():
+ """Verify that pager functions use list arguments instead of shell strings."""
+ from click._termui_impl import _pipepager, _tempfilepager
+
+ # Test _pipepager returns False when command not found
+ result = _pipepager(iter(["test"]), ["nonexistent_command_xyz"], None)
+ assert result is False
+
+ # Test _tempfilepager returns False when command not found
+ result = _tempfilepager(iter(["test"]), ["nonexistent_command_xyz"], None)
+ assert result is False
1 change: 1 addition & 0 deletions debian/patches/series
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
docs-Use-local-Python-intersphinx-inventary.patch
CVE-2026-7246.patch
Loading