Python's str.format() User-Controlled Template Trap: The Welcome Message That Leaks Your Signing Key

2026-08-28

Your product manager wants users to customize their welcome banner. Users pick a template on their settings page — something like "Hi {username}, welcome back!" — and the app renders it on every page load. Straightforward, right?

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

SECRET_KEY = "prod-signing-key-8f3c2a91e4"
DB_PASSWORD = "hunter2-but-worse"

def render_welcome(template: str, user: User) -> str:
    """Render a user-chosen welcome template.
    Supports {username} and {email} placeholders.
    """
    return template.format(username=user.name, email=user.email)


alice = User("Alice", "[email protected]")
print(render_welcome("Hi {username}, welcome back!", alice))
# -> Hi Alice, welcome back!

# Elsewhere, an attacker sets their template to:
evil = "{username.__class__.__init__.__globals__[SECRET_KEY]}"
print(render_welcome(evil, alice))
# -> prod-signing-key-8f3c2a91e4

The Bug

Python's str.format() isn't a simple string interpolator — it's a miniature expression language. Inside {...}, you can chain attribute access with . and item access with []. That's how {point.x} and {items[0]} work.

When the arguments to format are untrusted, you're fine — the template author controls what gets accessed. But when the template itself is attacker-controlled, they can walk the object graph of any argument you pass:

Any argument that's a Python object (which is all of them) becomes a portal to the entire module's globals: config, connection strings, in-memory caches, feature flags. In frameworks like Flask, this trick has produced real CVEs pulling app.config['SECRET_KEY'] out of log format strings.

The Fix

Never let untrusted input be the format string. Templates from users need a sandboxed engine. The safest built-in option is string.Template, which only does $name substitution — no attribute walking, no index access, no dunder traversal:

from string import Template

def render_welcome(template: str, user: User) -> str:
    return Template(template).safe_substitute(
        username=user.name,
        email=user.email,
    )

evil = "{username.__class__.__init__.__globals__[SECRET_KEY]}"
print(render_welcome(evil, alice))
# -> {username.__class__.__init__.__globals__[SECRET_KEY]}
# (rendered as literal text — $ syntax was required)

If you genuinely need {} syntax, subclass string.Formatter and override get_field to reject any field name containing . or [. Or reach for Jinja2 with autoescape and a restricted environment.

The general rule: the format string is code. Treat it with the same suspicion you'd give eval(). "literal".format(user_input) is safe; user_input.format(anything) is a remote data-exfiltration primitive.

Key Takeaway: str.format() is a mini expression language — if the template is attacker-controlled, every argument becomes a walkable object graph reaching straight into your module globals.

All newsletters