Sandboxed Environment

Add configurable defense-in-depth controls for risky templates

7 min read 1380 words

SandboxedEnvironment is a drop-in replacement for Environmentthat adds defense-in-depth controls for attribute access, function calls, imports, and resource consumption at render time.

Security boundary: The sandbox is not process isolation. Do not rely on it alone for adversarial template source. Curate the render context and add process/container controls for CPU, memory, filesystem, network, and time.

from kida import SandboxedEnvironment

All templates compiled by this environment are subject to sandbox restrictions. No code changes are needed in your templates -- the sandbox operates transparently at the engine level.

Quick Start

from kida import SandboxedEnvironment

env = SandboxedEnvironment()

# Safe: normal attribute access works
tmpl = env.from_string("Hello, {{ user.name }}!")
tmpl.render(user={"name": "Alice"})  # "Hello, Alice!"

# Blocked: dunder access raises SecurityError
tmpl = env.from_string("{{ user.__class__.__mro__ }}")
tmpl.render(user="hello")  # raises SecurityError

The default policy blocks all dunder attributes (except a safe subset like __len__ and __iter__), prevents access to and direct calls of untrusted Python function, type, and code objects, disables imports, and limitsrange() to 10,000 elements. Functions registered as environment globals and functions compiled from Kida{% def %}/ component templates are trusted call targets.

Security Policy Configuration

Customize the sandbox by passing aSandboxPolicyto the environment:

from kida.sandbox import SandboxPolicy, SandboxedEnvironment

policy = SandboxPolicy(
    allowed_attributes={"name", "title", "email", "items"},
    blocked_types={type, type(lambda: 0)},
    max_output_size=50_000,
    max_range=1000,
)
env = SandboxedEnvironment(sandbox_policy=policy)

All Options

Option Type Default Description
allowed_attributes frozenset[str] | None None If set, only these attribute names are accessible (plus safe collection methods).Nonemeans all non-blocked attributes are allowed.
blocked_attributes frozenset[str] frozenset() Additional attribute names to block, merged with the built-in blocked set.
blocked_types frozenset[type] frozenset() Object types that cannot be accessed at all. Function, type, and code objects are always blocked regardless of this setting.
allow_import bool False Whether__import__is available in templates.
allow_mutating_methods bool False Whether mutating collection methods (append, pop, clear, etc.) are accessible.
allow_calling frozenset[str] | None None Set of type names whose instances may be called.None permits non-blocked callable types; untrusted Python functions remain blocked. An empty frozenset()blocks all calls, including Kida component and environment-global calls.
max_output_size int | None None Maximum cumulative render output length in characters across full, block, sync-stream, and async-stream surfaces. RaisesSecurityError (K-SEC-005) when exceeded. Nonemeans unlimited.
max_range int 10000 Maximumrange()size allowed in templates.

SandboxPolicyis a frozen dataclass -- construct a new instance for each configuration.

Method Restrictions

The sandbox distinguishes between read-only and mutating methods on built-in collection and string types.

Read-Only Methods (Always Allowed)

These methods are allowed by default because they do not modify application state:

Type Methods
dict items, keys, values, get, copy
sequence count, index
set union, intersection, difference, issubset, issuperset, symmetric_difference
string startswith, endswith, strip, lstrip, rstrip, split, rsplit, join, replace, lower, upper, title, capitalize, format, encode, decode, find, rfind, removeprefix, removesuffix

Mutating Methods (Blocked by Default)

These methods are blocked unless the policy explicitly opts in:

append, extend, insert, pop, remove, sort, reverse, clear, update, add, discard

See Allowlists for how to enable them.

Dunder Access

Most dunder attributes are blocked. The following safe subset is permitted:

  • __len__ -- needed for lengthfilter and truthiness checks
  • __iter__ -- needed for {% for %}loops
  • __contains__ -- needed for inoperator
  • __getitem__ -- needed for bracket access (items[0])
  • __str__, __repr__-- needed for string output

All other dunder attributes (__class__, __subclasses__, __bases__, __mro__, __dict__, __globals__, etc.) are unconditionally blocked.

Call-Time Safety Checking

The sandbox intercepts every attribute access and function call at render time -- not at compile time. This means:

  1. Attribute access is checked against the policy before the value is returned. Blocked attributes raiseSecurityErrorimmediately.
  2. Type checks run on the object being accessed. If the object's type is in the unsafe set (orblocked_types), access is denied regardless of the attribute name.
  3. Function calls are always intercepted. Fundamentally unsafe callable types are denied unless the callable is an application-registered environment global or a Kida-compiled function. Whenallow_callingis configured, the callable's type name must also be in that allowlist.
from kida import SandboxedEnvironment, SecurityError

env = SandboxedEnvironment()

try:
    tmpl = env.from_string("{{ func.__globals__ }}")
    tmpl.render(func=lambda: None)
except SecurityError as e:
    print(e)  # "Access to attribute '__globals__' is blocked by sandbox policy"

The sandbox also replaces range()with a size-limited version:

env = SandboxedEnvironment()
tmpl = env.from_string("{% for i in range(999999) %}x{% endfor %}")
tmpl.render()  # raises SecurityError: range() size 999999 exceeds sandbox limit of 10000

Allowlists

allow_mutating_methods

Enable mutating methods when templates need to build data structures:

from kida.sandbox import SandboxPolicy, SandboxedEnvironment

policy = SandboxPolicy(allow_mutating_methods=True)
env = SandboxedEnvironment(sandbox_policy=policy)

tmpl = env.from_string("""
{% set items = [] %}
{% for name in names %}
  {% do items.append(name.upper()) %}
{% endfor %}
{{ items | join(", ") }}
""")
tmpl.render(names=["alice", "bob"])

Warning: Enabling mutating methods allows templates to modify objects passed in via the context. Only enable this when templates are semi-trusted or context objects are copies.

allow_calling

Restrict which types of callables templates can invoke:

from kida.sandbox import SandboxPolicy, SandboxedEnvironment

# Only allow calling methods on built-in types
policy = SandboxPolicy(
    allow_calling=frozenset({"builtin_function_or_method", "method"}),
)
env = SandboxedEnvironment(sandbox_policy=policy)

When allow_calling is None(the default), non-blocked callable types are permitted after attribute checks. Arbitrary Python functions supplied only in render context remain blocked; register trusted application functions with env.add_global(...). Set an empty frozenset()to block every function call, including Kida components and registered globals:

# Block all calls -- templates can only read attributes
policy = SandboxPolicy(allow_calling=frozenset())

allowed_attributes

Lock down attribute access to an explicit allowlist:

policy = SandboxPolicy(
    allowed_attributes=frozenset({"name", "email", "title", "created_at"}),
)
env = SandboxedEnvironment(sandbox_policy=policy)

# Only .name, .email, .title, .created_at are accessible
# (plus safe collection methods like .items, .keys, .get)

When allowed_attributesis set, any attribute not in the allowlist and not in the safe collection methods set is blocked.

Examples

CMS with User-Submitted Templates

from kida.sandbox import SandboxPolicy, SandboxedEnvironment

policy = SandboxPolicy(
    allowed_attributes=frozenset({
        "title", "body", "author", "published_at",
        "name", "email", "avatar_url",
        "items", "keys", "values",
    }),
    max_output_size=100_000,  # 100KB limit
    max_range=100,
)
env = SandboxedEnvironment(sandbox_policy=policy)

user_template = db.get_template(page_id)
tmpl = env.from_string(user_template)
html = tmpl.render(page=page, site=site_config)

Customer-Editable Email Templates

from kida.sandbox import SandboxPolicy, SandboxedEnvironment

policy = SandboxPolicy(
    allowed_attributes=frozenset({
        "first_name", "last_name", "email", "company",
        "order_id", "total", "items",
        "name", "quantity", "price",
    }),
    allow_calling=frozenset(),  # No function calls
    max_output_size=50_000,
    max_range=50,
)
env = SandboxedEnvironment(sandbox_policy=policy)

template_source = customer.get_email_template("welcome")
tmpl = env.from_string(template_source)
html = tmpl.render(user=user, order=order)

Plugin System with Semi-Trusted Code

from kida.sandbox import SandboxPolicy, SandboxedEnvironment

policy = SandboxPolicy(
    allow_mutating_methods=True,   # Plugins may build data
    allow_import=False,            # No imports
    blocked_attributes=frozenset({"password", "secret_key", "api_key"}),
    blocked_types=frozenset({type, type(lambda: 0)}),
    max_range=5000,
)
env = SandboxedEnvironment(sandbox_policy=policy)

Limitations

  • Compile-time only via runtime checks. The sandbox does not statically analyze templates. It intercepts attribute access and calls at render time, so a blocked operation only raisesSecurityErrorwhen the code path executes.
  • No CPU time limits. The sandbox limitsrange() size and output length, but cannot prevent infinite loops or expensive computations. Use OS-level timeouts (e.g., signal.alarmor process-level limits) for CPU protection.
  • No filesystem or network restrictions. The sandbox controls template-level access. If a context object exposes methods that perform I/O, the sandbox does not intercept the I/O itself -- only access to the method. Audit what you pass into the template context.
  • Mutating methods affect the caller. Whenallow_mutating_methods=True, templates can modify mutable objects passed in the context. Pass copies if you need to preserve originals.
  • Type-name matching for allow_calling. Theallow_calling option matches on type(obj).__name__, not the actual type object. Two unrelated types with the same __name__would both be permitted.

See Also