Skip to main content

Sandboxing Jinja2 Templates for Multi-Tenant Email

Let customers edit their own email templates safely: SandboxedEnvironment, attribute allowlists, render timeouts and output size caps, plus the escapes a plain Environment leaves open.

The moment customers can edit their own email templates, template rendering becomes remote code execution against your servers. This guide covers what a default Jinja2 environment exposes, what SandboxedEnvironment closes, and the resource limits that no sandbox provides.

Root Cause: Templates Are Programs With Access to Your Objects

Jinja2 templates evaluate expressions against the context you pass them, and Python objects carry attributes that lead well beyond the data you intended to expose. A standard Environment places no restriction on attribute access, so any object in the context is a starting point for reaching the interpreter itself.

The classic escape traverses from an ordinary object through its class, up the type hierarchy, and back down into loaded modules — a chain of attribute lookups that ends at something able to execute a subprocess. It requires no imports and no special syntax, only attribute access on a value you deliberately provided.

The exposure is not limited to code execution. A template that can read arbitrary attributes on the objects you pass can also read data you did not mean to render: a user object exposing a password hash, an order carrying an internal cost price, a request context holding another tenant's identifiers. In a multi-tenant system that is a data-isolation failure even without any code running.

Traversal from a context object Unrestricted attribute access lets a template walk from a supplied object up the type hierarchy and into loaded modules. Every Object You Pass Is an Entry Point your context order, customer its class attribute access the type hierarchy and its subclasses loaded modules subprocess, os No imports, no unusual syntax — only attribute lookups on a value you supplied deliberately. A plain Environment permits every step of this chain.
The traversal uses nothing but ordinary attribute access, which is why restricting that access is the whole of the defence.

The Exact Fix

SandboxedEnvironment intercepts attribute access and operator behaviour, blocking the traversal at its first step.

# sandbox.py — the rendering environment for customer-authored templates.
from jinja2.sandbox import SandboxedEnvironment
from jinja2 import StrictUndefined, select_autoescape
from markupsafe import escape


class EmailSandbox(SandboxedEnvironment):
    """Deny anything not explicitly needed, rather than blocking known escapes."""

    #: Attributes a customer template may never reach, whatever the object.
    BLOCKED_PREFIXES = ("_", "func_", "im_", "gi_", "cr_", "ag_")

    def is_safe_attribute(self, obj, attr, value):
        # The base class already blocks dunder access; this also removes the
        # single-underscore convention, which frequently holds internals.
        if attr.startswith(self.BLOCKED_PREFIXES):
            return False
        # Never expose anything callable from a template: a customer template
        # renders data, it does not invoke behaviour.
        if callable(value):
            return False
        return super().is_safe_attribute(obj, attr, value)

    def is_safe_callable(self, obj):
        # Nothing in a customer template should be callable at all.
        return False


def build_env():
    env = EmailSandbox(
        autoescape=select_autoescape(default_for_string=True, default=True),
        # A missing variable must fail loudly rather than render empty — a
        # silently blank total is a worse outcome than a failed render.
        undefined=StrictUndefined,
        trim_blocks=True,
        lstrip_blocks=True,
    )
    # Remove globals the sandbox permits by default but a template does not need.
    env.globals.clear()
    # Provide only the filters the documented template language includes.
    env.filters = {
        "currency": lambda v: f"£{v/100:,.2f}",
        "date": lambda v, fmt="%d %B %Y": v.strftime(fmt),
        "default": lambda v, d="": v if v else d,
        "e": escape,
    }
    return env

Two decisions go beyond the defaults. Nothing callable is exposed, because a customer template that renders data has no reason to invoke a method, and permitting calls re-opens most of the surface the sandbox closed. And the filter set is an allowlist rather than the default set minus removals — a denylist has to be revisited every time the library adds a filter, and one of those additions will eventually be exploitable.

Passing Plain Data Instead of Objects

The strongest measure is not a sandbox setting at all: build the render context out of plain dictionaries and primitives rather than out of your application's model objects.

An object passed into a template brings its entire attribute surface with it, and the sandbox is then the only thing standing between a customer's template and every field on your model — including ones added later by a developer who has never thought about templates. A flat dictionary containing exactly the fields the template language documents brings nothing else, and it stays correct as the model evolves.

def receipt_context(order, customer) -> dict:
    """Explicitly enumerate what a template may see. Adding a field here is a
    deliberate act; adding a column to the model exposes nothing."""
    return {
        "customer": {"first_name": customer.first_name, "email": customer.email},
        "order": {
            "reference": order.reference,
            "total_pence": order.total_pence,
            "items": [
                {"name": i.name, "qty": i.qty, "price_pence": i.price_pence}
                for i in order.items
            ],
        },
    }

This also makes the template language documentable. A customer can be told exactly which values exist, because the dictionary is the specification — and there is no possibility of an undocumented attribute working by accident and then breaking when the model changes.

Model objects versus an explicit context A model object exposes its whole attribute surface to the sandbox, while an explicit dictionary exposes only the documented fields. The Sandbox Is the Second Line, Not the First pass the model object every column, every relation every method, every internal a new column exposes itself the sandbox alone is the defence pass an explicit dictionary only the enumerated fields no methods, no relations a new column changes nothing the dictionary is the documentation
The dictionary approach is defence in depth and documentation at the same time: what a template can see is a list someone wrote down.

Resource Limits the Sandbox Does Not Provide

SandboxedEnvironment restricts what a template can reach. It says nothing about what a template can consume, and a template that reaches nothing at all can still take a worker down.

A nested loop over a modest collection produces quadratic work; a recursive macro can produce unbounded work; a loop emitting a large string on each iteration exhausts memory. None of these touch a blocked attribute, so the sandbox permits them all.

Three limits close the gap. A render timeout enforced by running the render in a separate process or with a signal-based alarm, so a slow template is killed rather than holding a worker indefinitely. An output size cap checked during rendering, so a template producing 500MB of output fails at the cap rather than at the memory limit. And a loop iteration cap, most simply enforced by ensuring the collections in the context are already bounded before they are passed.

Enforcing the timeout in a separate process rather than a thread is the detail that matters, because a CPU-bound Python loop does not yield, so a thread-based timeout never fires. The process boundary also contains a memory blowup, which a thread does not.

Testing the Sandbox Rather Than Trusting It

A sandbox configuration is a security control, and security controls that are never exercised drift. Three kinds of test keep it honest.

Escape attempts as unit tests. Write templates that try the known traversal patterns and assert each one raises rather than rendering. These read oddly in a test suite — a test whose body is an attack string — and they are the only thing that proves the configuration is still doing its job after someone upgrades the library or "simplifies" the environment setup.

Allowlist completeness. Assert that the filter set contains exactly the documented filters and no more. A test comparing the environment's filter keys against a literal list fails when a library upgrade adds a filter, which is precisely the moment someone should decide whether it is safe to expose.

Resource limit tests. Render a deliberately expensive template and assert that it is killed at the timeout rather than completing. This is the test most often omitted, because writing a template that takes exactly too long feels artificial — a nested loop over two thousand items is quite sufficient and takes seconds to write.

The pattern worth internalising is that the sandbox's failure mode is silent. A misconfigured environment renders customer templates perfectly well for months; nothing is visibly wrong until someone tries something. Tests are the only mechanism that surfaces the misconfiguration before an attacker does, which puts them in a different category from the tests covering ordinary rendering behaviour.

Tests that exercise the sandbox Escape-attempt tests, allowlist completeness tests and resource limit tests each catch a different way the configuration can drift. A Sandbox Nobody Tests Is a Sandbox Nobody Knows Works escape attempts must raise, not render Catches a downgraded environment after a refactor. allowlist completeness exactly the documented set Fails when a library upgrade adds something new. resource limits killed at the timeout The most often omitted, and the easiest to write.
The configuration renders customer templates perfectly well while misconfigured, which is why only a deliberate test surfaces the drift.

Validation and Deployment Checklist

Frequently Asked Questions

Is SandboxedEnvironment enough on its own?

It closes the code-execution path, which is the most severe risk, but it does not bound resource use and it does not stop a template reading any attribute you leave reachable. Treat it as one layer: sandbox, plus a plain-data context, plus resource limits. Relying on any one of the three alone leaves a real gap.

Can customers include one template from another?

Only through a loader you control, and the loader must scope lookups to the requesting tenant. A FileSystemLoader rooted at a shared directory lets one customer include another's template by name, which is a cross-tenant read even though nothing about it is a sandbox escape.

How do we let customers preview a template safely?

Render it in the same sandbox with the same limits, using fixture data rather than real customer records. Preview is often where the sandbox is weakest, because it is built later and by someone who assumes the production path already handled safety — and a preview endpoint rendering with a plain Environment is a complete bypass.

What should happen when a customer template fails to render?

Fail the send and surface a clear error to the customer, rather than sending a partially rendered message. StrictUndefined makes this the default for missing values, which is the correct trade — a failed send the customer can fix is better than a delivered message with a blank total that they discover from a support ticket.


← Back to Jinja2 for Python Apps