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',
(