str.format() User-Controlled Template Trap: The Welcome Message That Leaks Your Signing Key2026-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
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:
username is a str object.username.__class__ is <class 'str'>.username.__class__.__init__ is a function object.username.__class__.__init__.__globals__ is the dict of module globals where str.__init__ was defined — and every function you pass in gives access to its module globals.[SECRET_KEY] indexes that dict.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.
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.
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.
