diff options
Diffstat (limited to 'recipes-devtools/python')
| -rw-r--r-- | recipes-devtools/python/python3-dotenv/CVE-2026-28684.patch | 363 | ||||
| -rw-r--r-- | recipes-devtools/python/python3-dotenv_1.1.0.bb | 1 |
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 @@ | |||
| 1 | From 3fbba98d80cb3c6bfacf708923c79b9ee8a1489c Mon Sep 17 00:00:00 2001 | ||
| 2 | From: Bin Cao <bin.cao.cn@windriver.com> | ||
| 3 | Date: Wed, 29 Apr 2026 11:13:56 +0800 | ||
| 4 | Subject: [PATCH] Fix symlink following in set_key/unset_key | ||
| 5 | |||
| 6 | python-dotenv reads key-value pairs from a .env file and can set them as | ||
| 7 | environment variables. set_key() and unset_key() follow symbolic links | ||
| 8 | when rewriting .env files via shutil.move(), allowing a local attacker | ||
| 9 | to overwrite arbitrary files via a crafted symlink when a cross-device | ||
| 10 | rename fallback is triggered. | ||
| 11 | |||
| 12 | Fix by replacing shutil.move() with os.replace() and creating the temp | ||
| 13 | file in the same directory as the target to ensure atomic same-device | ||
| 14 | rename. Also preserve the original file mode and avoid blindly following | ||
| 15 | symlinks. Add follow_symlinks parameter to rewrite(), set_key(), and | ||
| 16 | unset_key() to allow opting in to the old behavior when needed. | ||
| 17 | |||
| 18 | Backported from upstream commit 790c5c02991100aa1bf41ee5330aca75edc51311 | ||
| 19 | to v1.1.0. | ||
| 20 | |||
| 21 | CVE: CVE-2026-28684 | ||
| 22 | Upstream-Status: Backport [https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311] | ||
| 23 | Signed-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 | |||
| 30 | diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py | ||
| 31 | index 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) | ||
| 63 | diff --git a/src/dotenv/main.py b/src/dotenv/main.py | ||
| 64 | index 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 | ||
| 207 | diff --git a/tests/test_main.py b/tests/test_main.py | ||
| 208 | index 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 | -- | ||
| 362 | 2.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" | |||
| 8 | PYPI_PACKAGE = "python_dotenv" | 8 | PYPI_PACKAGE = "python_dotenv" |
| 9 | UPSTREAM_CHECK_PYPI_PACKAGE = "${PYPI_PACKAGE}" | 9 | UPSTREAM_CHECK_PYPI_PACKAGE = "${PYPI_PACKAGE}" |
| 10 | 10 | ||
| 11 | SRC_URI += "file://CVE-2026-28684.patch" | ||
| 11 | SRC_URI[sha256sum] = "41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5" | 12 | SRC_URI[sha256sum] = "41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5" |
| 12 | 13 | ||
| 13 | inherit pypi setuptools3 | 14 | inherit pypi setuptools3 |
