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.
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.
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.
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.
Related
- Jinja2 for Python Apps — the environment configuration this hardens
- Migrating From Handlebars to Jinja2 — where autoescaping must be turned on explicitly
- Email Queueing and Retry Architecture — where a render timeout is enforced in the worker
- Modern Email Templating Engines — how customer-editable templates fit the wider architecture
← Back to Jinja2 for Python Apps