How do you render a user-supplied str.format template safely in a webhook receiver?
answer
- Supply the grammar instead of hardening one
- A dollar sign has no dots
- Refuse the whole chained field name
- Pass primitives, not your own objects
- Reject the template at save time
basics
~10 sNever hand untrusted text to str.format. Prefer string.Template, whose $name placeholders have no attribute or item syntax; if the mini-language is required, subclass string.Formatter, allowlist field names in get_field, and pass only pre-stringified primitives.
solid answer
~50 sCustomers controlling the *shape* of a message is a fair requirement; customers controlling *what is read* is not, and `str.format` conflates the two because its field names allow `{0.attr}` and `{0[key]}`. So supply the placeholder language yourself. First choice is `string.Template`: only `$name` and `${name}`, no dots, no brackets, `substitute` failing closed with `KeyError`. If you genuinely need format specs, subclass `string.Formatter` and override `get_field` — it receives the whole chained field name, so it is the layer that can refuse `customer.__init__.__globals__[KEY]` — driving it with `vformat`. Then starve the context: a flat mapping of already-stringified primitives, never a settings module or one of your own instances. Validate at save time with `string.Formatter().parse()`, capping width and precision, and re-validate at render behind a flag so a bad stored template can be shut off without waiting for the next three-week release train.
code
python · 26 linesimport string
ALLOWED = {"customer", "event", "amount"}
class StrictFormatter(string.Formatter):
def get_field(self, field_name, args, kwargs):
if field_name not in ALLOWED:
raise ValueError(f"field not allowed: {field_name!r}")
return kwargs[field_name], field_name
def format_field(self, value, format_spec):
if len(format_spec) > 8:
raise ValueError("format spec too long")
return super().format_field(value, format_spec)
fmt = StrictFormatter()
values = {"customer": "ada", "event": "delivery.failed", "amount": "42.00"}
print(fmt.vformat("{event} for {customer}", (), values))
for bad in ("{customer.__init__.__globals__[KEY]}", "{amount:>100000000}"):
try:
fmt.vformat(bad, (), values)
except ValueError as exc:
print("rejected:", exc)go deeper
Know the rule before the mechanism: a template that came from a user does not go into str.format. Be able to name string.Template as a placeholder syntax that has no attribute or item access to abuse.
Explain the two workable designs — a smaller grammar, or a string.Formatter subclass that allowlists field names — and why the check belongs in get_field. Be ready to say what you would pass as arguments.
Layer the answer: grammar choice, interposition, a starved argument context, save-time validation with Formatter().parse(), and a render-time re-check you can tighten without a release. Name the width amplifier and say what you log.
Decide whether customer-authored templates are a capability the product should offer at all, and if so where that renderer lives so there is exactly one. Own the migration for templates already stored under the old rules and the failure policy while it runs.
### The requirement, stated honestly A webhook receiver that lets each customer configure the message body it renders for an event has a genuine product requirement: untrusted text must control the *shape* of an output string. The security requirement is that the untrusted text must not control **what is read**. Those two are compatible only if you supply the placeholder language yourself instead of handing the string to `str.format`, whose mini-language grants attribute access (`{0.attr}`) and item access (`{0[key]}`) to whoever wrote the template. ### Layer 1 — pick a grammar with nothing to abuse The strongest move is to stop using the mini-language at all. `string.Template` accepts `$name` and `${name}` and nothing else: there is no dot, no bracket, no format spec, so there is no walk to perform. `substitute` raises `KeyError` on an unknown placeholder (fail closed); `safe_substitute` leaves it as literal text (fail open) — choose deliberately, and prefer the first for anything customers edit. ```python import string string.Template("$customer.__class__").substitute(customer="ada") # 'ada.__class__' ``` The dot is just a character. That is the whole point: you cannot harden a grammar as effectively as you can decline to offer one. ### Layer 2 — if the mini-language is required, interpose Sometimes you need alignment and number formatting and cannot give it up. Then own the resolution step: subclass `string.Formatter`, override `get_field` so a field name is only ever an allowlisted key with no dots or brackets, override `format_field` to cap the spec, and drive it with `vformat`. ```python class StrictFormatter(string.Formatter): def get_field(self, field_name, args, kwargs): if field_name not in ALLOWED: raise ValueError(f"field not allowed: {field_name!r}") return kwargs[field_name], field_name ``` Overriding `get_field` rather than `get_value` matters: `get_field` receives the whole chained field name, so it is the layer that can see and refuse `customer.__init__.__globals__[KEY]`. A check placed one level lower only ever sees `customer` and passes it happily. ### Layer 3 — starve the context Whatever renders the template should receive a **flat mapping of already-stringified primitives**, assembled explicitly per event type. Never pass the request object, the settings module, an object whose class is defined in your code, or anything holding a live client. If a check is ever missed, the reachable graph from a `str` is type metadata; the reachable graph from one of your own instances is `__init__.__globals__` and every module-level secret behind it. Defence in depth here is cheap and it is the layer that survives your own bugs. ### Layer 4 — validate at save time, not at render time Parse the template when the customer saves it, with `string.Formatter().parse()`, which yields `(literal_text, field_name, format_spec, conversion)` tuples. Reject unknown field names, any dot or bracket, and an oversized width or precision — `"{0:>100000000}".format("x")` allocates a hundred million characters, so an unbounded spec is a memory-amplification bug even after the attribute walk is closed. Rejecting on save gives the customer an immediate, actionable error; rejecting at render time produces a 3 a.m. page and a half-delivered webhook. ### The operational layer Assume you will find a bad stored template after ship. Re-validate at render time as well as at save time, behind a flag you can flip, so remediation does not have to wait for the next three-week release train: revalidating on read lets you tighten the allowlist centrally and immediately, and gives you the list of customers whose templates would now fail, which is the migration plan. Log the rejected field name, never the rendered output — the output is precisely the thing that may contain the secret you were protecting. ### What not to do, and why * **Blocklisting `__globals__`, `__class__` or underscores.** The chain has many routes and the grammar is richer than the blocklist; you are enumerating badness. * **Escaping braces in the values.** The values were never the attacker's input; the template was. * **Switching to `str.format_map`.** Identical field-name grammar; only the lookup differs. * **Scanning the rendered output for secrets.** By then the read has happened, and you are matching patterns against data you do not control the shape of. ### Version context `string.Template` and `string.Formatter` behave identically across 3.10–3.14. Template strings (t-strings, PEP 750, **3.14**) are worth knowing here but do not solve this problem: a `t"..."` literal lives in your source, so it governs how *values* reach a sink, not who writes the template.
- Why override string.Formatter.get_field rather than get_value?`get_field` is handed the entire field name, dots and brackets included, and is the method that performs the attribute and item walk. A check there sees `customer.__init__.__globals__[KEY]` in full and can refuse it. `get_value` is called after the first component has already been split off, so it only ever sees `customer` and passes it — the walk then proceeds inside the base implementation, untouched by your check.
- Even with attribute access blocked, what can a stored template still do?Allocate. The format spec is applied after resolution, and `"{0:>100000000}".format("x")` builds a hundred-million-character string from a fifteen-character template; precision on a float behaves similarly. Parse the template on save with `string.Formatter().parse()`, which yields the literal text, field name, format spec and conversion, and reject or cap oversized widths there so the rejection reaches the customer instead of your memory limit.
- What should the render call actually receive as arguments?A flat mapping built explicitly for that event type, holding only already-stringified primitives. Never the request object, the settings module, a client or one of your own class instances: from a `str` the reachable graph is type metadata, while from your own instance it is `__init__.__globals__` and every module-level secret behind it. It is the layer that keeps a missed check from becoming a disclosure.
saying these in an interview costs you the question
- Blocklists __globals__ or underscores and calls it done
- Escapes braces in the values instead of controlling the template
- Passes the settings module or request object as an argument
- Renders first, then scans the output for secrets
- Treats str.format_map as the hardened variant
- Ignores width and precision as a memory amplifier