Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-pydantic-settings for 
openSUSE:Factory checked in at 2026-06-25 10:58:33
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-pydantic-settings (Old)
 and      /work/SRC/openSUSE:Factory/.python-pydantic-settings.new.2088 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-pydantic-settings"

Thu Jun 25 10:58:33 2026 rev:14 rq:1361573 version:2.14.2

Changes:
--------
--- 
/work/SRC/openSUSE:Factory/python-pydantic-settings/python-pydantic-settings.changes
        2026-06-15 19:47:02.308452366 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-pydantic-settings.new.2088/python-pydantic-settings.changes
      2026-06-25 11:00:52.684955212 +0200
@@ -1,0 +2,7 @@
+Mon Jun 22 12:20:33 UTC 2026 - Nico Krapp <[email protected]>
+
+- Update 2.14.2 (fixes bsc#1268680)
+  * Prevent NestedSecretsSettingsSource from following symlinks outside
+    secrets_dir
+
+-------------------------------------------------------------------

Old:
----
  pydantic_settings-2.14.1.tar.gz

New:
----
  pydantic_settings-2.14.2.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-pydantic-settings.spec ++++++
--- /var/tmp/diff_new_pack.ZncpLm/_old  2026-06-25 11:00:53.364978654 +0200
+++ /var/tmp/diff_new_pack.ZncpLm/_new  2026-06-25 11:00:53.368978792 +0200
@@ -26,7 +26,7 @@
 %endif
 %{?sle15_python_module_pythons}
 Name:           python-pydantic-settings%{psuffix}
-Version:        2.14.1
+Version:        2.14.2
 Release:        0
 Summary:        Settings management using Pydantic
 License:        MIT

++++++ pydantic_settings-2.14.1.tar.gz -> pydantic_settings-2.14.2.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/pydantic_settings-2.14.1/PKG-INFO 
new/pydantic_settings-2.14.2/PKG-INFO
--- old/pydantic_settings-2.14.1/PKG-INFO       2020-02-02 01:00:00.000000000 
+0100
+++ new/pydantic_settings-2.14.2/PKG-INFO       2020-02-02 01:00:00.000000000 
+0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: pydantic-settings
-Version: 2.14.1
+Version: 2.14.2
 Summary: Settings management using Pydantic
 Project-URL: Homepage, https://github.com/pydantic/pydantic-settings
 Project-URL: Funding, https://github.com/sponsors/samuelcolvin
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/pydantic_settings-2.14.1/pydantic_settings/sources/providers/nested_secrets.py
 
new/pydantic_settings-2.14.2/pydantic_settings/sources/providers/nested_secrets.py
--- 
old/pydantic_settings-2.14.1/pydantic_settings/sources/providers/nested_secrets.py
  2020-02-02 01:00:00.000000000 +0100
+++ 
new/pydantic_settings-2.14.2/pydantic_settings/sources/providers/nested_secrets.py
  2020-02-02 01:00:00.000000000 +0100
@@ -1,7 +1,7 @@
 import os
 import warnings
+from collections.abc import Iterator
 from functools import reduce
-from glob import iglob
 from pathlib import Path
 from typing import TYPE_CHECKING, Any, Literal, Optional
 
@@ -146,17 +146,61 @@
         else:
             if not path.is_dir():
                 raise SettingsError(f'secrets_dir must reference a directory, 
not a {path_type_label(path)}')
-            secrets_dir_size = sum(f.stat().st_size for f in path.glob('**/*') 
if f.is_file())
+            secrets_dir_size = sum(f.stat().st_size for f in 
self._iter_secret_files(path))
             if secrets_dir_size > self.secrets_dir_max_size:
                 raise SettingsError(f'secrets_dir size is above 
{self.secrets_dir_max_size} bytes')
 
     @staticmethod
-    def load_secrets(path: Path) -> dict[str, str]:
-        return {
-            str(p.relative_to(path)): p.read_text().strip()
-            for p in map(Path, iglob(f'{path}/**/*', recursive=True))
-            if p.is_file()
-        }
+    def _iter_secret_files(path: Path) -> Iterator[Path]:
+        """Yield the secret files contained in ``path``.
+
+        ``path`` is expected to already be resolved. The directory tree is 
walked
+        explicitly so that symbolic links are handled safely:
+
+        * a file is only yielded if its real location stays within ``path``; 
entries
+          that resolve outside of it (e.g. through a symbolic link) are 
skipped, so
+          they neither contribute to the ``secrets_dir_max_size`` accounting 
nor get
+          loaded;
+        * each real directory is visited at most once, so cyclic or repeated
+          symlinks cannot make the walk loop and inflate the size accounting 
or the
+          number of loaded secrets.
+
+        Because the size check and the loader share this iterator, they always 
see
+        the same set of files.
+        """
+        seen_dirs: set[Path] = set()
+
+        def walk(directory: Path) -> Iterator[Path]:
+            # Guard against symlink loops / a directory reachable through 
multiple
+            # links being traversed more than once.
+            resolved_dir = directory.resolve()
+            if resolved_dir in seen_dirs:
+                return
+            seen_dirs.add(resolved_dir)
+            try:
+                entries = sorted(directory.iterdir())
+            except OSError:
+                return
+            for entry in entries:
+                resolved = entry.resolve()
+                if resolved.is_dir():
+                    # Only descend into directories that stay within 
secrets_dir.
+                    # A symlinked directory pointing outside of ``path`` is not
+                    # followed at all, so we never walk (potentially large) 
external
+                    # trees and never read files from outside secrets_dir.
+                    if resolved == path or path in resolved.parents:
+                        yield from walk(entry)
+                elif resolved.is_file() and path in resolved.parents:
+                    # Defense in depth: a file whose real location escapes
+                    # secrets_dir (e.g. a symlink pointing outside of 
``path``) is
+                    # skipped from both the size accounting and the load.
+                    yield entry
+
+        yield from walk(path)
+
+    @classmethod
+    def load_secrets(cls, path: Path) -> dict[str, str]:
+        return {str(p.relative_to(path)): p.read_text().strip() for p in 
cls._iter_secret_files(path)}
 
     def __repr__(self) -> str:
         return f'NestedSecretsSettingsSource(secrets_dir={self.secrets_dir!r})'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/pydantic_settings-2.14.1/pydantic_settings/version.py 
new/pydantic_settings-2.14.2/pydantic_settings/version.py
--- old/pydantic_settings-2.14.1/pydantic_settings/version.py   2020-02-02 
01:00:00.000000000 +0100
+++ new/pydantic_settings-2.14.2/pydantic_settings/version.py   2020-02-02 
01:00:00.000000000 +0100
@@ -1 +1 @@
-VERSION = '2.14.1'
+VERSION = '2.14.2'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/pydantic_settings-2.14.1/tests/test_source_nested_secrets.py 
new/pydantic_settings-2.14.2/tests/test_source_nested_secrets.py
--- old/pydantic_settings-2.14.1/tests/test_source_nested_secrets.py    
2020-02-02 01:00:00.000000000 +0100
+++ new/pydantic_settings-2.14.2/tests/test_source_nested_secrets.py    
2020-02-02 01:00:00.000000000 +0100
@@ -1,5 +1,7 @@
 from enum import Enum
 from os import sep
+from pathlib import Path
+from unittest.mock import patch
 
 import pytest
 from pydantic import BaseModel
@@ -195,6 +197,165 @@
     }
 
 
+def test_symlink_dir_escaping_secrets_dir_is_ignored(env, tmp_path):
+    """Regression test for GHSA-4xgf-cpjx-pc3j.
+
+    A directory entry inside ``secrets_dir`` that is a symbolic link to a 
directory
+    *outside* of ``secrets_dir`` must not be followed: its files must not leak 
into
+    settings values.
+    """
+    env.set('DB__USER', 'user')
+
+    secrets_dir = tmp_path / 'secrets'
+    secrets_dir.mkdir()
+    outside = tmp_path / 'outside'
+    outside.mkdir()
+    outside.joinpath('passwd').write_text('leaked-secret')
+
+    # secrets/db -> ../outside (escapes secrets_dir)
+    secrets_dir.joinpath('db').symlink_to(outside)
+
+    class Settings(AppSettings):
+        model_config = SettingsConfigDict(
+            secrets_dir=secrets_dir,
+            env_nested_delimiter='__',
+            secrets_nested_subdir=True,
+        )
+
+    # the out-of-tree file is not read; passwd keeps its default
+    assert Settings().model_dump() == {
+        'app_key': None,
+        'db': {'user': 'user', 'passwd': None},
+    }
+
+
+def test_symlink_file_escaping_secrets_dir_is_ignored(env, tmp_path):
+    """Regression test for GHSA-4xgf-cpjx-pc3j (file-level symlink variant)."""
+    env.set('DB__USER', 'user')
+
+    secrets_dir = tmp_path / 'secrets'
+    (secrets_dir / 'db').mkdir(parents=True)
+    outside = tmp_path / 'outside'
+    outside.mkdir()
+    outside.joinpath('passwd').write_text('leaked-secret')
+
+    # secrets/db/passwd -> ../../outside/passwd (escapes secrets_dir)
+    secrets_dir.joinpath('db', 'passwd').symlink_to(outside / 'passwd')
+
+    class Settings(AppSettings):
+        model_config = SettingsConfigDict(
+            secrets_dir=secrets_dir,
+            env_nested_delimiter='__',
+            secrets_nested_subdir=True,
+        )
+
+    assert Settings().model_dump() == {
+        'app_key': None,
+        'db': {'user': 'user', 'passwd': None},
+    }
+
+
+def test_symlink_escape_does_not_bypass_max_size(env, tmp_path):
+    """Regression test for GHSA-4xgf-cpjx-pc3j (size-limit bypass).
+
+    The ``secrets_dir_max_size`` accounting must see the same files as the 
loader.
+    An out-of-tree file reached through a symlink previously counted as 0 
bytes in
+    the size check (``Path.glob``) while still being read by the loader
+    (``glob.iglob(recursive=True)``). After the fix it is excluded from both, 
so the
+    large out-of-tree file neither trips the size cap nor leaks into settings.
+    """
+    env.set('DB__USER', 'user')
+
+    secrets_dir = tmp_path / 'secrets'
+    secrets_dir.mkdir()
+    outside = tmp_path / 'outside'
+    outside.mkdir()
+    # 512 bytes, well above the 100 byte cap below
+    outside.joinpath('passwd').write_text('S' * 512)
+    secrets_dir.joinpath('db').symlink_to(outside)
+
+    class Settings(AppSettings):
+        model_config = SettingsConfigDict(
+            secrets_dir=secrets_dir,
+            env_nested_delimiter='__',
+            secrets_nested_subdir=True,
+            secrets_dir_max_size=100,
+        )
+
+    # No SettingsError, and the out-of-tree payload is not loaded.
+    assert Settings().model_dump() == {
+        'app_key': None,
+        'db': {'user': 'user', 'passwd': None},
+    }
+
+
+def test_cyclic_symlink_does_not_inflate_size(env, tmp_path):
+    """Regression test for GHSA-4xgf-cpjx-pc3j (resource-consumption / 
CWE-400).
+
+    A cyclic (or repeated) symlink inside ``secrets_dir`` must not cause the
+    directory walk to loop. Otherwise a single small file gets visited many 
times,
+    inflating the ``secrets_dir_max_size`` accounting (and the number of loaded
+    secrets). Each real directory must be traversed at most once.
+    """
+    env.set('DB__USER', 'user')
+
+    secrets_dir = tmp_path / 'secrets'
+    (secrets_dir / 'db').mkdir(parents=True)
+    secrets_dir.joinpath('db', 'passwd').write_text('secret2')
+    # secrets/db/loop -> secrets (cycle): a naive recursive glob would revisit
+    # passwd dozens of times.
+    secrets_dir.joinpath('db', 'loop').symlink_to(secrets_dir)
+
+    class Settings(AppSettings):
+        model_config = SettingsConfigDict(
+            secrets_dir=secrets_dir,
+            env_nested_delimiter='__',
+            secrets_nested_subdir=True,
+            # passwd is 7 bytes; with the cycle un-guarded the walk would 
count it
+            # many times and exceed this cap.
+            secrets_dir_max_size=50,
+        )
+
+    # The walk terminates, counts passwd once, and loads it exactly once.
+    assert Settings().model_dump() == {
+        'app_key': None,
+        'db': {'user': 'user', 'passwd': 'secret2'},
+    }
+
+
+def test_symlinked_dir_escaping_secrets_dir_is_not_walked(tmp_path):
+    """A symlinked directory pointing outside ``secrets_dir`` must not be 
traversed.
+
+    Even though out-of-tree files are filtered out, descending into the 
external
+    tree wastes I/O and contradicts the intent of not following symlinks 
outside
+    ``secrets_dir`` (potential DoS via a large external tree). The walk must 
not
+    ``iterdir`` anything outside of ``secrets_dir``.
+    """
+    secrets_dir = tmp_path / 'secrets'
+    secrets_dir.mkdir()
+    secrets_dir.joinpath('legit').write_text('ok')
+
+    external = tmp_path / 'external'
+    (external / 'deep').mkdir(parents=True)
+    external.joinpath('deep', 'passwd').write_text('leaked')
+    secrets_dir.joinpath('link').symlink_to(external)
+
+    resolved_secrets = secrets_dir.resolve()
+    walked: list[Path] = []
+    original_iterdir = Path.iterdir
+
+    def tracking_iterdir(self):
+        walked.append(self.resolve())
+        return original_iterdir(self)
+
+    with patch.object(Path, 'iterdir', tracking_iterdir):
+        files = 
list(NestedSecretsSettingsSource._iter_secret_files(resolved_secrets))
+
+    # only the in-tree file is yielded, and nothing outside secrets_dir is 
walked
+    assert [f.name for f in files] == ['legit']
+    assert all(d == resolved_secrets or resolved_secrets in d.parents for d in 
walked), walked
+
+
 @pytest.mark.parametrize(
     'conf,secrets,dirs,expected',
     (

Reply via email to