David Lord

An improved Python `hasattr` for Jinja

By figuring out a fix for a security issue in Jinja, I came up with a more robust version of Python's hasattr.

One interesting difference between Python and Jinja is that obj.attr (attribute lookup) falls back to obj["attr"] (container lookup) and vice versa. When Jinja is compiled, every occurrence of obj.attr is replaced with env.getattr(obj, attr) behind the scenes.

import typing as t

class Environment:
    def getattr(self, obj: t.Any, name: str) -> t.Any:
        try:
            return getattr(obj, name)
        except AttributeError:
            try:
                return obj[name]
            except (TypeError, LookupError, AttributeError):
                return self.undefined(obj, name)

SandboxedEnvironment overrides the getattr method to ensure that the attribute is not unsafe, such as preventing access to __dunder__ names. Many of the recent security reports we've fixed deal with str.format: "{0.__init__.__globals__.__builtins__[__import__]}".format(obj) hides attribute access inside the string. The sandbox handles this, but only if it can see that str.format was accessed. I just fixed another such security issue, GHSA-cpwx-vrp4-4pq7, fixed in Jinja 3.1.6.

Jinja has an |attr filter that is documented to only try obj.attr and not fall back to obj[attr]. To do this, it was not calling env.getattr, instead implementing its own try/except code. It actually did account for some behavior of the sandbox as well, but missed the check for str.format. So "..." | attr("format") would return the un-sandboxed str.format method.

I considered a few ways to fix this. I wanted to call env.getattr rather than having separate code, but you can't tell from the return value whether it used obj.attr or obj["attr"]. I could add a no_fallback parameter to getattr, but that would be an immediate breaking change for any other project that was subclassing and overriding the method. I could add a new getattr_only method, but that would also bypass any subclass that still had its logic in getattr.

I realized that I could check if the attribute exists before calling env.getattr, so I could know that it would not need to use the fallback. But checking if an attribute exists in Python has a few quirks. Consider the following:

import typing as t

class Speaker:
    @property
    def is_available(self) -> bool:
        print("expensive database lookup")
        return True

    def __getattr__(self, attr: str) -> t.Any:
        if (name := attr.removeprefix("greet_") != attr:
            def greet() -> str:
                print(f"Hello, {name}!")

        raise AttributeError(attr)
>>> s = Speaker()
>>> s.is_available
expensive database lookup
True
>>> s.greet_World()
Hello, World!

The obvious pick is the built-in hasattr. However, this is a wrapper around getattr, which means that properties and other descriptors are accessed and evaluated, and need to be evaluated again when actually getting the attribute after the check. Calling hasattr(s, "is_available") would print expensive database lookup. Calling hasattr(s, "greet_reader") would perform the dynamic lookup and return True, but would also construct the function.

There's also inspect.getattr_static. This will get an attribute without evaluating properties and other descriptors. However, it also does not evaluate __getattr__ and __getattribute__ methods, so it doesn't find dynamic attributes. hasattr(s, "is_available") would return True without printing, but hasattr(s, "greet_reader") would return False.

At first I considered the tradeoff. getattr_static is safer, but it will also miss some cases. The |attr filter is unlikely to be used much in the first place, especially on expensive dynamic attributes, so maybe the extra work caused by hasattr is fine. Performance, or correctness? I decided to go with both! Here's the commit to Jinja. And here's a standalone version:

import typing as t
from inspect import getattr_static

def robust_hasattr(obj: t.Any, attr: str) -> bool:
    try:
        getattr_static(obj, attr)
        return True
    except AttributeError:
        return hasattr(obj, attr)
    
    return False

This first uses getattr_static to check if the attribute exists without executing anything. This will return True for any defined attribute including properties and other descriptors. If that fails, it tries hasattr, which will find dynamic attributes, but we know it won't evaluate properties at that point.

This still has some very minor performance penalty, although I didn't try to measure it. The gettattr_static implementation is complex Python code. The except block introduces some minor overhead. hasattr can still potentially cause extra work.

This isn't needed for most use cases, so don't go copying it into your own code unless you know you need it. But for Jinja's security needs, this was a clever, succinct solution with an acceptable tradeoff.

#Jinja #Python #security