skip to content

Why is calling str.format on a user-supplied template string dangerous?

level: seniorimportance: should knowfreq 40%

answer

  1. Templates from users are executable-ish input
  2. A field is not just a name
  3. Dots and brackets walk the object graph
  4. Every function carries its module namespace
  5. Width has no upper bound

basics

~20 s

A hostile template walks attributes of the arguments you pass, so "{row.init.globals[SECRET]}" reads module globals and leaks secrets, and a huge width such as "{v:>100000000}" allocates hundreds of megabytes per render. Render untrusted templates with string.Template instead.

solid answer

~50 s

A `str.format` field is not just a name — it may also walk attributes with `.attr` and index with `[key]`. So a template supplied by a user can traverse from any argument you pass to that object's methods, to a function's `__globals__`, and out into module-level secrets: `"{row.__init__.__globals__[SIGNING_KEY]}"` prints the key. There are no calls in that grammar, so this is information disclosure rather than direct code execution — but a leaked signing key or database URL is usually enough. The second failure mode is resource exhaustion: width and precision are unbounded, so `"{v:>100000000}"` builds a hundred-megabyte string from a fifteen-byte template. The fixes, strongest first: do not accept a template at all; accept `string.Template` with its `$name` grammar, which has no attribute access and no format specs; or subclass `string.Formatter` and reject any field name that is not an allow-listed identifier and any non-empty spec.

code

python · 11 lines
python
SIGNING_KEY = "payroll-signing-key"

class Row:
    def __init__(self, employee):
        self.employee = employee

leak = "{row.__init__.__globals__[SIGNING_KEY]}"
print(leak.format(row=Row("ada")))          # secret disclosed

bomb = "{v:>10000000}"                       # 20 characters of template
print(len(bomb.format(v="x")))               # 10_000_000 characters of output

go deeper

for a junior

The takeaway to carry is simple: a template string that came from a user is untrusted input, not text. Never pass it to str.format; reach for string.Template, whose $name syntax cannot do anything clever.

for a middle

Explain the mechanism, not just the rule: a replacement field permits attribute access and indexing, so it walks from your arguments into a function's __globals__. Know that string.Template closes it because its grammar has no such syntax.

for a senior

Diagnose and fix a live one. Name both failure modes — global disclosure and unbounded width — say what an incident review would look for in stored templates, and choose a mitigation with reasons rather than reciting string.Template by reflex.

for a principal

Own the framing: a user-editable template is a small embedded language, and shipping one is a decision about how much of your object graph users may read. Decide whether the feature is worth a constrained renderer at all, and where validation lives so it is enforced once rather than at every call site.

### The scenario A payroll system imports timesheet CSVs, and someone adds a convenience: administrators can configure the one-line summary that each imported row produces, stored as a template string in the database and rendered with `row_template.format(row=row, batch=batch)`. It ships on a three-week release train and nobody flags it, because the people who can edit templates are trusted staff. Two problems arrive with it, and only one of them needs a malicious insider. ### Failure mode one: attribute traversal A `str.format` replacement field is more than a name. Its grammar allows a positional index or a keyword, followed by any number of `.attr` accesses and `[key]` lookups. That is a read-only walk, but it starts at whatever objects you handed to `format`, and Python objects are richly connected. From any instance you can reach its class, from a class you can reach a function it defines, and every Python function carries `__globals__` — the module namespace it was defined in, complete with whatever the module holds at import time: signing keys, API tokens, database URLs. So: ```python "{row.__init__.__globals__[SIGNING_KEY]}".format(row=some_row) ``` prints the key. No calls happen, which is why this is disclosure rather than direct code execution — the field grammar has no call syntax at all — but a template that can read arbitrary module globals is usually the end of the story anyway. Passing only plain scalars narrows it considerably: a `str` argument exposes little of interest, because the builtin types' methods are C-level slot wrappers with no `__globals__` dict. It does **not** close the hole, and it is fragile — the next change that passes a rich domain object, a settings object, or `self` reopens it silently. Never render an untrusted template with `**locals()` or `**vars(config)`. ### Failure mode two: unbounded memory growth The second problem needs no attacker at all, only a typo. Width and precision in the format spec are unbounded integers, so a fifteen-character template can demand an enormous allocation: ```python len("{v:>100000000}".format(v="x")) # 100_000_000 ``` That is a hundred megabytes of padding per rendered row. Run it once per line of a large import and the worker's resident memory climbs until the process is killed — a template-driven allocation, so it looks nothing like a normal leak in the metrics. Precision does the same on floats: an absurd `.precision` on a fixed-point conversion produces a similarly enormous string. The `%` operator shares this weakness even though it cannot traverse attributes. ### The mitigation ladder **1. Do not accept a template.** Most "configurable message" features are really a choice of which fields to include and in what order. A checklist of column names plus a separator gives users what they wanted and leaves you with a template you generated yourself. This is the fix; everything below it is damage control. **2. Use `string.Template`.** Its grammar is `$name` and `${name}` and nothing else: no attribute access, no indexing, no format specs, so both failure modes vanish by construction. `Template.substitute` raises `KeyError` for an unknown placeholder and `ValueError` for malformed syntax; `Template.safe_substitute` leaves unresolved placeholders in the output instead, which is what you want when a half-valid template must not take down the import. **3. Restrict `str.format` with a `string.Formatter` subclass.** Override `get_field` to reject any field name that is not a plain identifier from an allow-list — `str.isidentifier` plus a set membership test is enough — and override `format_field` to reject a non-empty spec, or to parse and bound the width. This keeps the familiar `{name}` syntax for users who already have templates. **4. Validate at the boundary as well as at render time.** Templates are stored data; check them when they are saved, so a bad one is rejected by the person who wrote it rather than by the batch job at 3 a.m. ### What about f-strings? An f-string cannot be the vulnerability here, because it cannot be the feature: it is a literal compiled into your source, and there is no way to supply one at runtime. The only way to make user text behave like an f-string is `eval`, and that is not a smaller version of this problem — it is arbitrary code execution, strictly worse than reading globals. If you see `eval` used to render a template, that is the finding. Separately, 3.14 added t-strings as a different mechanism for the inverse problem — safely rendering *values* into a structured target — but they too are literals, so they do not make a user-supplied template safe either.

  • If the field grammar has no call syntax, how bad can a hostile str.format template really get?
    It is read-only traversal, so there is no direct code execution — but it can reach any attribute or dict entry hanging off the arguments you passed, including a function's `__globals__` and from there the whole module namespace. Signing keys, credentials and connection strings live there. Rate it as an information-disclosure bug of whatever severity the secrets it reaches deserve, which is usually critical.
  • Does passing only plain strings and integers make str.format safe for untrusted templates?
    It shrinks the reachable graph a lot, because builtin types expose C-level slot wrappers with no `__globals__` dict to walk into. It is not a fix. It leaves the unbounded width and precision denial of service untouched, and it silently breaks the day someone passes a domain object or a settings instance. Constrain the template grammar instead of hoping the arguments stay boring.
  • How would you catch a bad template before it reaches the batch job?
    Validate on write, not only on render. When a template is saved, parse it with `string.Formatter.parse`, reject any field name that is not an allow-listed identifier, reject non-empty specs or bound the width, and render it once against sample values. That way the person who wrote the template gets the error, and a malformed one can never be pulled out of the database in the middle of an import run.

Handing a user a str.format template is like letting them write the SELECT list of a query against your process memory: no function calls allowed, but they choose what to read.

saying these in an interview costs you the question

  • Assuming only f-strings can be dangerous
  • Believing str.format cannot read attributes
  • Escaping braces in the values instead of constraining the template
  • Thinking passing plain strings makes it safe
  • Ignoring unbounded width as a denial-of-service vector
  • Suggesting eval to render a user template

context