What goes wrong when setattr writes object fields named by a client-supplied payload?
answer
- The name is data, the write is real
- Assignment creates attributes that did not exist
- A bad write raises nothing at all
- Internal limits and flags are ordinary names
- Allowlist writable fields, reject the rest
basics
~20 sLooping setattr over a request dict lets the caller rebind any attribute the object has, including internal limits and flags, and create new ones. Nothing raises, so the effect is silent. Check each key against an allowlist of writable fields first.
solid answer
~50 s`setattr(obj, key, value)` is `obj.key = value` with the name as data, so a loop over a client-supplied mapping gives the client write access to the object's entire attribute surface. That includes internal state the API never advertised — a batch limit, a permission flag, a cached total — and names that do not exist yet, since assignment creates them. Unlike a bad read, a bad write leaves no exception behind: the object keeps working with a value the client chose, and the symptom surfaces later and somewhere else, as wrong output rather than an error. The fix is the same shape as for reads: a `frozenset` of writable field names, checked before the call, with anything else rejected — and a schema-validating layer that produces typed values rather than assigning raw payload values straight onto a live object.
code
python · 18 linesclass PickList:
def __init__(self):
self.rows = ["a", "b", "c"]
self.max_rows = 500
def build(self):
return self.rows[: self.max_rows]
payload = {"rows": ["a", "b", "c"], "max_rows": 1} # client-supplied field names
target = PickList()
for key, value in payload.items():
setattr(target, key, value) # rebinds an internal limit
print(target.build()) # silently truncated
WRITABLE = frozenset({"rows"})
rejected = [key for key in payload if key not in WRITABLE]
print("rejected:", rejected)go deeper
Recall that setattr is assignment with the name as data, and that assignment creates an attribute that did not exist. So a payload key you never designed for becomes a real field, without any error.
Explain why the write case is worse than the read case: no exception is raised, the object keeps working, and the wrong value shows up later as bad output. Show the writable-field allowlist.
Demonstrate the production angle — an internal limit or flag rebound by a client produces plausible wrong results that survive testing — and argue for rejecting unknown fields loudly instead of dropping them.
Own the boundary shape: payloads are parsed into typed objects with documented fields before anything is applied, so dynamic attribute writes do not exist in application code and the review rule has almost no exceptions.
## The pattern and why it appears The construct is familiar from update endpoints and internal builders: ```python for key, value in payload.items(): setattr(target, key, value) ``` It is written to avoid repeating a dozen assignments, and it works beautifully until the payload keys come from outside. `setattr` is `obj.name = value` with the name supplied as a value; it does no filtering, and assignment in Python **creates** an attribute that does not already exist rather than failing. So a client that names a field gets to write it, whether or not it is part of the API. ## Concrete failure: a silent truncation Consider a warehouse pick-list builder that accepts an update payload for a pick list. The object carries the fields the API documents — the line items, the destination — and also internal state: a `max_rows` cap that bounds how many lines a printed sheet may hold. The endpoint loops `setattr` over the payload. A client sends `max_rows` alongside the documented fields, and from then on the builder trims every list it produces to that value. Notice what does *not* happen. No exception. No validation error. No log line. The pick lists come out short, pickers work the sheet they are handed, and the stock counts drift. The report that finally surfaced it in one team's telling arrived after the change had been live across a three-week release train, by which point the wrong sheets were indistinguishable from correct ones and the reconciliation had to be done by hand. That asymmetry is the heart of the lesson: an attacker-controlled *read* usually produces an error or an obviously wrong response, while an attacker-controlled *write* produces a plausible one. The same shape reaches further when the object carries a flag the rest of the code trusts — a `validated` marker, an `is_admin` cache, a `dry_run` switch. Rebinding a boolean that some later branch reads is a complete authorisation bypass with no invalid input anywhere in sight. ## Why the usual mitigations do not hold * **"The object only has the fields we defined."** It also has whatever a base class defined, and after the first stray write, whatever the client named. `__slots__` will raise on a name outside the declared set — a real narrowing, and a side effect of a memory optimisation, not a security control. It still permits writes to every declared slot. * **"We reject keys starting with an underscore."** The interesting internal fields are usually ordinary names, because nobody marks a batch limit private. * **"We validate the values."** Type-checking the value does not authorise the *name*. A perfectly valid integer written to the wrong attribute is exactly this bug. * **"The ORM or model layer will catch it."** Only if the write goes through that layer. `setattr` on the instance frequently bypasses whatever the framework's own update path would have checked. ## The safe shape Bind the writable surface explicitly, and prefer building a new validated object over mutating a live one: ```python WRITABLE = frozenset({"destination", "line_items"}) unknown = set(payload) - WRITABLE if unknown: raise ValueError(f"fields not writable: {sorted(unknown)}") for key in WRITABLE & payload.keys(): setattr(target, key, payload[key]) ``` Two design refinements worth stating in an interview. First, **reject rather than ignore** unknown fields: silently dropping them hides both client bugs and probing, and it is the behaviour that lets a bad integration run for weeks. Second, prefer a **parse step** — a dataclass or schema layer that turns the payload into a typed object with exactly the documented fields, which you then apply field by field. Once such a layer exists, `setattr` with a computed name disappears from the code entirely, which is the real goal: not a safer dynamic write, but no dynamic write. ## In review `setattr` with a non-literal name is worth the same question as `getattr`: what is the complete set of names this can write, and who decides it? Because failures here are silent, the review is the main control — there is no exception to catch and often no log to alert on. It is also worth checking `vars(obj).update(payload)` and direct `obj.__dict__.update(payload)`, which are the same bug wearing different clothes and slip past a search for `setattr`.
- Does defining __slots__ on the class solve this?It narrows it. With __slots__ and no __dict__, assigning a name outside the declared set raises AttributeError, so the client cannot invent new attributes. Every declared slot is still writable, and the internal fields you care about — a limit, a flag, a cached total — are usually declared slots. Treat it as a side benefit of a memory optimisation, not as the control; the allowlist is the control.
- Should unknown payload fields be ignored or rejected?Rejected, with the field names named in the server-side log and a generic message to the client. Silently ignoring them hides client bugs, hides schema drift after a rename, and hides probing, so the first signal you get is wrong data rather than a failed request. Ignoring is defensible only for a deliberately forward-compatible wire format, and even then the drops should be counted.
- What other constructs are this same bug in disguise?Anything that writes the instance namespace from untrusted keys: obj.__dict__.update(payload), vars(obj).update(payload), and a __init__ or classmethod that takes **kwargs and assigns them wholesale. A grep for setattr misses all three, which is why the review question is about who decides the writable name set rather than about which function performs the write.
It is a form where the client also gets to name the fields, so they can fill in boxes that were never printed on the page.
saying these in an interview costs you the question
- Says a bad attribute write will raise an error
- Validates value types but never the field name
- Assumes the object only has the fields it declared
- Silently ignores unknown payload keys
- Thinks the model layer catches a direct instance write
- Filters underscored keys and calls it done