VYPR
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.

PackageAffected versionsPatched versions
dynaconfPyPI
< 3.2.133.2.13

Affected products

1

Patches

1
2fbb45ee36b8

Fix @jinja and @format templating vulnerabilities

https://github.com/dynaconf/dynaconfPedro BrochadoMar 13, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.