High severity7.5NVD Advisory· Published Mar 20, 2026· Updated Apr 14, 2026
CVE-2026-33154
CVE-2026-33154
Description
dynaconf is a configuration management tool for Python. Prior to version 3.2.13, Dynaconf is vulnerable to Server-Side Template Injection (SSTI) due to unsafe template evaluation in the @Jinja resolver. When the jinja2 package is installed, Dynaconf evaluates template expressions embedded in configuration values without a sandboxed environment. This issue has been patched in version 3.2.13.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
dynaconfPyPI | < 3.2.13 | 3.2.13 |
Affected products
1Patches
12fbb45ee36b8Fix @jinja and @format templating vulnerabilities
4 files changed · +111 −12
dynaconf/base.py+26 −7 modified@@ -436,12 +436,6 @@ def _dotted_get( :param default: In case of not found it will be returned :param parent: Is there a pre-loaded parent in a nested data? """ - # if parent is not traverseable raise error - if parent and not hasattr(parent, "get"): - raise AttributeError( - f"cannot lookup {dotted_key!r} from {type(parent).__name__!r}" - ) - split_key = dotted_key.split(".") name, keys = split_key[0], split_key[1:] result = self.get(name, default=default, parent=parent, **kwargs) @@ -454,6 +448,13 @@ def _dotted_get( return parse_conf_data(result, tomlfy=True, box_settings=self) return result + # Still keys left, but current result/parent is not a data container + if keys and not isinstance(result, (dict, list)): + result_type = type(result).__name__ + raise AttributeError( + f"Invalid dotted lookup in {dotted_key}. {name} is a {result_type}" + ) + # If we've still got key elements to traverse, let's do that. return self._dotted_get( ".".join(keys), default=default, parent=result, cast=cast, **kwargs @@ -533,7 +534,7 @@ def get( self.unset(key) self.execute_loaders(key=key) - data = (parent or self.store).get(key, default) + data = _get_with_default(parent or self.store, key, default) if cast: data = apply_converter(cast, data, box_settings=self) return data @@ -1534,3 +1535,21 @@ def is_overridden(self, setting): # noqa "_REGISTERED_HOOKS", ] ) + +# These are special fields defined by Dynaconf, but users can access it +_PUBLIC_PROPERTIES = [name for name, _ in inspect.getmembers(Settings, lambda + x: isinstance(x, property))] + + +def _get_with_default(data: dict | list, key: str, default): + if isinstance(data, dict): + return data.get(key, default) + elif isinstance(data, list): + if not key.isdigit(): + raise ValueError(f"Expected integer, got: {key}") + try: + return data[int(key)] + except KeyError: + return default + else: + raise AttributeError(f"Unknown data container type: {type(data)}")
dynaconf/utils/parse_conf.py+37 −5 modified@@ -3,6 +3,7 @@ import json import os import re +import string import warnings from functools import wraps @@ -16,9 +17,10 @@ from dynaconf.vendor import tomllib try: - from jinja2 import Environment + import jinja2 + from jinja2.sandbox import SandboxedEnvironment - jinja_env = Environment() + jinja_env = SandboxedEnvironment() for p_method in ("abspath", "realpath", "relpath", "dirname", "basename"): jinja_env.filters[p_method] = getattr(os.path, p_method) except ImportError: # pragma: no cover @@ -211,12 +213,16 @@ def __str__(self): return str(self.token) -def _jinja_formatter(value, **context): +def _jinja_formatter(value: str, **context) -> str: if jinja_env is None: # pragma: no cover raise ImportError( "jinja2 must be installed to enable '@jinja' settings in dynaconf" ) - return jinja_env.from_string(value).render(**context) + try: + return jinja_env.from_string(value).render(**context) + except jinja2.exceptions.SecurityError: + warnings.warn(f"Unsafe access attempt to: {value}") + return "" def _get_formatter(value, **context): @@ -259,10 +265,36 @@ def _get_formatter(value, **context): return context["this"].get(**params) +class SafeFormatter(string.Formatter): + def get_field(self, field_name, args, context): + self._validate_key_exists(field_name, context) + return super().get_field(field_name, args, context) + + def _validate_key_exists(self, field_name: str, context): + if not field_name.lower().startswith("this"): + return + from dynaconf.base import _PUBLIC_PROPERTIES + + field_name = field_name.replace("[", ".") + field_name = field_name.replace("]", "") + context_name, _, key = field_name.partition(".") + # these are accesible by the user, but are not considered setting keys + # e.g, settings.current_env + if key in _PUBLIC_PROPERTIES: + return + # allow only existing setting keys + if key not in context[context_name]: + raise AttributeError(key) + + +def _format_formatter(input: str, **context) -> str: + return SafeFormatter().format(input, **context) + + class Formatters: """Dynaconf builtin formatters""" - python_formatter = BaseFormatter(str.format, "format") + python_formatter = BaseFormatter(_format_formatter, "format") jinja_formatter = BaseFormatter(_jinja_formatter, "jinja") get_formatter = BaseFormatter(_get_formatter, "get")
tests/test_endtoend.py+42 −0 modified@@ -1,6 +1,13 @@ from __future__ import annotations import os +from contextlib import AbstractContextManager as ContextManager +from contextlib import nullcontext + +import pytest + +from dynaconf.utils.parse_conf import DynaconfFormatError +from dynaconf.utils.parse_conf import DynaconfParseError def test_end_to_end(settings): @@ -65,3 +72,38 @@ def test_boxed_data_call(settings): assert settings("boxed_data").params.password == "secret" assert settings("boxed_data").params.token.type == 1 assert settings("boxed_data").params.token.value == 2 + + +@pytest.mark.parametrize( + "template_string, expectation, warn", + [ + pytest.param( + "@jinja {{ cycler.__init__.__globals__.os.popen('id').read() }}", + nullcontext(""), + pytest.warns(UserWarning, match="Unsafe"), + id="jinja-returns-empty-string", + ), + pytest.param( + "@format {this.__class__.__init__.__globals__[os].environ}", + pytest.raises(DynaconfFormatError), + nullcontext("no warning"), + id="format-raises", + ), + pytest.param( + "@get __class__.__init__.__globals__[os].environ", + pytest.raises(DynaconfParseError), + nullcontext("no warning"), + id="get-raises", + ), + ], +) +def test_templating_safety( + template_string: str, expectation: ContextManager, warn: ContextManager +): + from dynaconf import Dynaconf + + settings = Dynaconf(malicious=template_string) + + with expectation as result: + with warn: + assert settings.malicious == result
tests/test_utils.py+6 −0 modified@@ -401,6 +401,12 @@ def get(self, key, default=None): return getattr(self, key) return parse_conf_data("@format {this.FOO}/bar", box_settings=self) + def __contains__(self, key): + value = getattr(self, key, missing) + if value is missing: + return False + return True + settings = Settings() assert settings.get("foo") == "foo/bar"
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/dynaconf/dynaconf/commit/2fbb45ee36b8c0caa5b924fe19f3c1a5e8603fa7nvdPatchWEB
- github.com/dynaconf/dynaconf/security/advisories/GHSA-pxrr-hq57-q35pnvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-pxrr-hq57-q35pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33154ghsaADVISORY
- github.com/dynaconf/dynaconf/releases/tag/3.2.13nvdRelease NotesWEB
News mentions
0No linked articles in our index yet.