summaryrefslogtreecommitdiffstats
path: root/recipes-devtools/python
diff options
context:
space:
mode:
authorBin Cao <bin.cao.cn@windriver.com>2026-04-29 16:57:18 +0800
committerBruce Ashfield <bruce.ashfield@gmail.com>2026-04-29 20:01:23 +0000
commite1beca39e85e32a767469eb6869875fec0009a02 (patch)
tree7078582cb167e4b8e9e8863c3961a7b923e5f6a1 /recipes-devtools/python
parentf262327c7a994c38c325f100ec23bbbf38daf75d (diff)
downloadmeta-virtualization-e1beca39e85e32a767469eb6869875fec0009a02.tar.gz
python3-dotenv: Fix CVE-2026-28684
Backported from [1], verified with the test script from [2]. [1] https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 [2] https://github.com/theskumar/python-dotenv/security/advisories/GHSA-mf9w-mj56-hr94 [3] https://nvd.nist.gov/vuln/detail/CVE-2026-28684 Signed-off-by: Bin Cao <bin.cao.cn@windriver.com> Signed-off-by: Bruce Ashfield <bruce.ashfield@gmail.com>
Diffstat (limited to 'recipes-devtools/python')
-rw-r--r--recipes-devtools/python/python3-dotenv/CVE-2026-28684.patch363
-rw-r--r--recipes-devtools/python/python3-dotenv_1.1.0.bb1
2 files changed, 364 insertions, 0 deletions
diff --git a/recipes-devtools/python/python3-dotenv/CVE-2026-28684.patch b/recipes-devtools/python/python3-dotenv/CVE-2026-28684.patch
new file mode 100644
index 00000000..3302c32e
--- /dev/null
+++ b/recipes-devtools/python/python3-dotenv/CVE-2026-28684.patch
@@ -0,0 +1,363 @@
1From 3fbba98d80cb3c6bfacf708923c79b9ee8a1489c Mon Sep 17 00:00:00 2001
2From: Bin Cao <bin.cao.cn@windriver.com>
3Date: Wed, 29 Apr 2026 11:13:56 +0800
4Subject: [PATCH] Fix symlink following in set_key/unset_key
5
6python-dotenv reads key-value pairs from a .env file and can set them as
7environment variables. set_key() and unset_key() follow symbolic links
8when rewriting .env files via shutil.move(), allowing a local attacker
9to overwrite arbitrary files via a crafted symlink when a cross-device
10rename fallback is triggered.
11
12Fix by replacing shutil.move() with os.replace() and creating the temp
13file in the same directory as the target to ensure atomic same-device
14rename. Also preserve the original file mode and avoid blindly following
15symlinks. Add follow_symlinks parameter to rewrite(), set_key(), and
16unset_key() to allow opting in to the old behavior when needed.
17
18Backported from upstream commit 790c5c02991100aa1bf41ee5330aca75edc51311
19to v1.1.0.
20
21CVE: CVE-2026-28684
22Upstream-Status: Backport [https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311]
23Signed-off-by: Bin Cao <bin.cao.cn@windriver.com>
24---
25 src/dotenv/cli.py | 15 +++++-
26 src/dotenv/main.py | 72 ++++++++++++++++++++-----
27 tests/test_main.py | 129 +++++++++++++++++++++++++++++++++++++++++++++
28 3 files changed, 201 insertions(+), 15 deletions(-)
29
30diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py
31index 33ae148..259345b 100644
32--- a/src/dotenv/cli.py
33+++ b/src/dotenv/cli.py
34@@ -93,7 +93,13 @@ def list(ctx: click.Context, format: bool) -> None:
35 @click.argument('key', required=True)
36 @click.argument('value', required=True)
37 def set(ctx: click.Context, key: Any, value: Any) -> None:
38- """Store the given key/value."""
39+ """
40+ Store the given key/value.
41+
42+ This doesn't follow symlinks, to avoid accidentally modifying a file at a
43+ potentially untrusted path.
44+ """
45+
46 file = ctx.obj['FILE']
47 quote = ctx.obj['QUOTE']
48 export = ctx.obj['EXPORT']
49@@ -125,7 +131,12 @@ def get(ctx: click.Context, key: Any) -> None:
50 @click.pass_context
51 @click.argument('key', required=True)
52 def unset(ctx: click.Context, key: Any) -> None:
53- """Removes the given key."""
54+ """
55+ Removes the given key.
56+
57+ This doesn't follow symlinks, to avoid accidentally modifying a file at a
58+ potentially untrusted path.
59+ """
60 file = ctx.obj['FILE']
61 quote = ctx.obj['QUOTE']
62 success, key = unset_key(file, key, quote)
63diff --git a/src/dotenv/main.py b/src/dotenv/main.py
64index 1848d60..821bb9b 100644
65--- a/src/dotenv/main.py
66+++ b/src/dotenv/main.py
67@@ -2,7 +2,7 @@ import io
68 import logging
69 import os
70 import pathlib
71-import shutil
72+import stat
73 import sys
74 import tempfile
75 from collections import OrderedDict
76@@ -13,9 +13,7 @@ from .parser import Binding, parse_stream
77 from .variables import parse_variables
78
79 # A type alias for a string path to be used for the paths in this file.
80-# These paths may flow to `open()` and `shutil.move()`; `shutil.move()`
81-# only accepts string paths, not byte paths or file descriptors. See
82-# https://github.com/python/typeshed/pull/6832.
83+# These paths may flow to `open()` and `os.replace()`.
84 StrPath = Union[str, "os.PathLike[str]"]
85
86 logger = logging.getLogger(__name__)
87@@ -131,21 +129,54 @@ def get_key(
88 def rewrite(
89 path: StrPath,
90 encoding: Optional[str],
91+ follow_symlinks: bool = False,
92 ) -> Iterator[Tuple[IO[str], IO[str]]]:
93- pathlib.Path(path).touch()
94+ if follow_symlinks:
95+ path = os.path.realpath(path)
96
97- with tempfile.NamedTemporaryFile(mode="w", encoding=encoding, delete=False) as dest:
98+ try:
99+ source: IO[str] = open(path, encoding=encoding)
100+ try:
101+ path_stat = os.lstat(path)
102+ original_mode: Optional[int] = (
103+ stat.S_IMODE(path_stat.st_mode)
104+ if stat.S_ISREG(path_stat.st_mode)
105+ else None
106+ )
107+ except BaseException:
108+ source.close()
109+ raise
110+ except FileNotFoundError:
111+ source = io.StringIO("")
112+ original_mode = None
113+
114+ with tempfile.NamedTemporaryFile(
115+ mode="w",
116+ encoding=encoding,
117+ delete=False,
118+ prefix=".tmp_",
119+ dir=os.path.dirname(os.path.abspath(path)),
120+ ) as dest:
121+ dest_path = pathlib.Path(dest.name)
122 error = None
123+
124 try:
125- with open(path, encoding=encoding) as source:
126+ with source:
127 yield (source, dest)
128 except BaseException as err:
129 error = err
130
131 if error is None:
132- shutil.move(dest.name, path)
133+ try:
134+ if original_mode is not None:
135+ os.chmod(dest_path, original_mode)
136+
137+ os.replace(dest_path, path)
138+ except BaseException:
139+ dest_path.unlink(missing_ok=True)
140+ raise
141 else:
142- os.unlink(dest.name)
143+ dest_path.unlink(missing_ok=True)
144 raise error from None
145
146
147@@ -156,12 +187,16 @@ def set_key(
148 quote_mode: str = "always",
149 export: bool = False,
150 encoding: Optional[str] = "utf-8",
151+ follow_symlinks: bool = False,
152 ) -> Tuple[Optional[bool], str, str]:
153 """
154 Adds or Updates a key/value to the given .env
155
156- If the .env path given doesn't exist, fails instead of risking creating
157- an orphan .env somewhere in the filesystem
158+ The target .env file is created if it doesn't exist.
159+
160+ This function doesn't follow symlinks by default, to avoid accidentally
161+ modifying a file at a potentially untrusted path. If you don't need this
162+ protection and need symlinks to be followed, use `follow_symlinks`.
163 """
164 if quote_mode not in ("always", "auto", "never"):
165 raise ValueError(f"Unknown quote_mode: {quote_mode}")
166@@ -179,7 +214,10 @@ def set_key(
167 else:
168 line_out = f"{key_to_set}={value_out}\n"
169
170- with rewrite(dotenv_path, encoding=encoding) as (source, dest):
171+ with rewrite(dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks) as (
172+ source,
173+ dest,
174+ ):
175 replaced = False
176 missing_newline = False
177 for mapping in with_warn_for_invalid_lines(parse_stream(source)):
178@@ -202,19 +240,27 @@ def unset_key(
179 key_to_unset: str,
180 quote_mode: str = "always",
181 encoding: Optional[str] = "utf-8",
182+ follow_symlinks: bool = False,
183 ) -> Tuple[Optional[bool], str]:
184 """
185 Removes a given key from the given `.env` file.
186
187 If the .env path given doesn't exist, fails.
188 If the given key doesn't exist in the .env, fails.
189+
190+ This function doesn't follow symlinks by default, to avoid accidentally
191+ modifying a file at a potentially untrusted path. If you don't need this
192+ protection and need symlinks to be followed, use `follow_symlinks`.
193 """
194 if not os.path.exists(dotenv_path):
195 logger.warning("Can't delete from %s - it doesn't exist.", dotenv_path)
196 return None, key_to_unset
197
198 removed = False
199- with rewrite(dotenv_path, encoding=encoding) as (source, dest):
200+ with rewrite(dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks) as (
201+ source,
202+ dest,
203+ ):
204 for mapping in with_warn_for_invalid_lines(parse_stream(source)):
205 if mapping.key == key_to_unset:
206 removed = True
207diff --git a/tests/test_main.py b/tests/test_main.py
208index 2d63eec..fbae934 100644
209--- a/tests/test_main.py
210+++ b/tests/test_main.py
211@@ -1,6 +1,7 @@
212 import io
213 import logging
214 import os
215+import stat
216 import sys
217 import textwrap
218 from unittest import mock
219@@ -61,6 +62,86 @@ def test_set_key_encoding(dotenv_path):
220 assert dotenv_path.read_text(encoding=encoding) == "a='é'\n"
221
222
223+@pytest.mark.skipif(
224+ sys.platform == "win32", reason="file mode bits behave differently on Windows"
225+)
226+def test_set_key_preserves_file_mode(dotenv_path):
227+ dotenv_path.write_text("a=x\n")
228+ dotenv_path.chmod(0o640)
229+ mode_before = stat.S_IMODE(dotenv_path.stat().st_mode)
230+
231+ dotenv.set_key(dotenv_path, "a", "y")
232+
233+ mode_after = stat.S_IMODE(dotenv_path.stat().st_mode)
234+ assert mode_before == mode_after
235+
236+
237+def test_rewrite_closes_file_handle_on_lstat_failure(tmp_path):
238+ dotenv_path = tmp_path / ".env"
239+ dotenv_path.write_text("a=x\n")
240+ real_open = open
241+ opened_handles = []
242+
243+ def tracking_open(*args, **kwargs):
244+ handle = real_open(*args, **kwargs)
245+ opened_handles.append(handle)
246+ return handle
247+
248+ with mock.patch("dotenv.main.os.lstat", side_effect=FileNotFoundError):
249+ with mock.patch("dotenv.main.open", side_effect=tracking_open):
250+ dotenv.set_key(dotenv_path, "a", "x")
251+
252+ assert opened_handles, "expected at least one file to be opened"
253+ assert all(handle.closed for handle in opened_handles)
254+
255+
256+@pytest.mark.skipif(
257+ sys.platform == "win32", reason="symlinks require elevated privileges on Windows"
258+)
259+def test_set_key_symlink_to_existing_file(tmp_path):
260+ target = tmp_path / "target.env"
261+ target.write_text("a=x\n")
262+ symlink = tmp_path / ".env"
263+ symlink.symlink_to(target)
264+
265+ dotenv.set_key(symlink, "a", "y")
266+
267+ assert target.read_text() == "a=x\n"
268+ assert not symlink.is_symlink()
269+ assert "a='y'" in symlink.read_text()
270+ assert stat.S_IMODE(symlink.stat().st_mode) == 0o600
271+
272+
273+@pytest.mark.skipif(
274+ sys.platform == "win32", reason="symlinks require elevated privileges on Windows"
275+)
276+def test_set_key_symlink_to_missing_file(tmp_path):
277+ target = tmp_path / "nx"
278+ symlink = tmp_path / ".env"
279+ symlink.symlink_to(target)
280+
281+ dotenv.set_key(symlink, "a", "x")
282+
283+ assert not target.exists()
284+ assert not symlink.is_symlink()
285+ assert symlink.read_text() == "a='x'\n"
286+
287+
288+@pytest.mark.skipif(
289+ sys.platform == "win32", reason="symlinks require elevated privileges on Windows"
290+)
291+def test_set_key_follow_symlinks(tmp_path):
292+ target = tmp_path / "target.env"
293+ target.write_text("a=x\n")
294+ symlink = tmp_path / ".env"
295+ symlink.symlink_to(target)
296+
297+ dotenv.set_key(symlink, "a", "y", follow_symlinks=True)
298+
299+ assert target.read_text() == "a='y'\n"
300+ assert symlink.is_symlink()
301+
302+
303 def test_set_key_permission_error(dotenv_path):
304 dotenv_path.chmod(0o000)
305
306@@ -188,6 +269,54 @@ def test_unset_non_existent_file(tmp_path):
307 )
308
309
310+@pytest.mark.skipif(
311+ sys.platform == "win32", reason="symlinks require elevated privileges on Windows"
312+)
313+def test_unset_key_symlink_to_existing_file(tmp_path):
314+ target = tmp_path / "target.env"
315+ target.write_text("a=x\n")
316+ symlink = tmp_path / ".env"
317+ symlink.symlink_to(target)
318+
319+ dotenv.unset_key(symlink, "a")
320+
321+ assert target.read_text() == "a=x\n"
322+ assert not symlink.is_symlink()
323+ assert symlink.read_text() == ""
324+
325+
326+@pytest.mark.skipif(
327+ sys.platform == "win32", reason="symlinks require elevated privileges on Windows"
328+)
329+def test_unset_key_symlink_to_missing_file(tmp_path):
330+ target = tmp_path / "nx"
331+ symlink = tmp_path / ".env"
332+ symlink.symlink_to(target)
333+ logger = logging.getLogger("dotenv.main")
334+
335+ with mock.patch.object(logger, "warning") as mock_warning:
336+ result = dotenv.unset_key(symlink, "a")
337+
338+ assert result == (None, "a")
339+ assert symlink.is_symlink()
340+ mock_warning.assert_called_once()
341+
342+
343+@pytest.mark.skipif(
344+ sys.platform == "win32", reason="symlinks require elevated privileges on Windows"
345+)
346+def test_unset_key_follow_symlinks(tmp_path):
347+ target = tmp_path / "target.env"
348+ target.write_text("a=b\n")
349+ symlink = tmp_path / ".env"
350+ symlink.symlink_to(target)
351+
352+ dotenv.unset_key(symlink, "a", follow_symlinks=True)
353+
354+ assert target.read_text() == ""
355+ assert symlink.is_symlink()
356+
357+
358 def prepare_file_hierarchy(path):
359 """
360 Create a temporary folder structure like the following:
361--
3622.43.0
363
diff --git a/recipes-devtools/python/python3-dotenv_1.1.0.bb b/recipes-devtools/python/python3-dotenv_1.1.0.bb
index 7e20a444..04c2c0b8 100644
--- a/recipes-devtools/python/python3-dotenv_1.1.0.bb
+++ b/recipes-devtools/python/python3-dotenv_1.1.0.bb
@@ -8,6 +8,7 @@ LIC_FILES_CHKSUM = "file://LICENSE;md5=e914cdb773ae44a732b392532d88f072"
8PYPI_PACKAGE = "python_dotenv" 8PYPI_PACKAGE = "python_dotenv"
9UPSTREAM_CHECK_PYPI_PACKAGE = "${PYPI_PACKAGE}" 9UPSTREAM_CHECK_PYPI_PACKAGE = "${PYPI_PACKAGE}"
10 10
11SRC_URI += "file://CVE-2026-28684.patch"
11SRC_URI[sha256sum] = "41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5" 12SRC_URI[sha256sum] = "41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5"
12 13
13inherit pypi setuptools3 14inherit pypi setuptools3