How do you harden a getattr-based dispatcher that genuinely has to stay dynamic?
answer
- Ask what the complete reachable set is
- Allowlist beats any blocklist
- Underscore filtering is the classic mistake
- Resolve against a small purpose-built object
- Check the resolved object is callable
basics
~20 sTest the incoming name for membership in an explicit set of permitted names before the lookup, resolve against a purpose-built namespace object rather than a live service class, and verify the result is the kind of callable you expect. Never filter by rejecting underscores.
solid answer
~40 sThree properties make a dynamic dispatcher defensible. First, **allowlist not blocklist**: check `name in ALLOWED` against a frozenset or a registry dict you built, so the reachable surface cannot grow when someone adds a method to a class. Rejecting names that start with `_` is the classic blocklist mistake — every public and inherited method stays reachable. Second, **a narrow target object**: resolve against a small namespace whose only attributes are actions, not against a request handler or service that also carries connections, config and internal helpers. Third, **validate the result**, not just the name: confirm it is callable and came from where you expect before invoking it. Build the registry at import time with a decorator so adding an action is deliberate, and log the rejected name rather than echoing it back.
code
python · 25 linesALLOWED_ACTIONS = frozenset({"pick", "pack"})
class PickList:
def pick(self):
return "picked"
def pack(self):
return "packed"
def _reset(self):
return "reset"
def run(target, action):
if action not in ALLOWED_ACTIONS:
raise ValueError(f"unknown action: {action!r}")
return getattr(target, action)()
print(run(PickList(), "pick"))
try:
run(PickList(), "__class__")
except ValueError as exc:
print(exc)go deeper
Recall the safe shape: check the name against a fixed set of allowed names before any lookup happens, and remember that filtering out underscores is not that check.
Explain why a blocklist cannot hold — the class and its bases keep gaining methods — and how a decorator-built registry makes exposure a deliberate, reviewable act.
Show layered thinking: allowlist as the control, a narrow purpose-built target object so a mistake is survivable, validation of the resolved callable, and a failure path that neither echoes the name nor leaks a traceback.
Own the standard: dynamic dispatch is allowed only where names are registered, and the codebase gets a rule that flags getattr and setattr with a non-literal name so the exceptions are visible and few.
## When dynamic really is required Most `getattr` dispatch should simply become a dict. But some designs genuinely resolve names at runtime: a plugin system where third-party packages contribute actions, a rules engine where operators enable checks by name, a formatter table generated from a schema. The interview question is what disciplines make that defensible, and the answer has more structure than "validate the input". ## 1. Allowlist, never blocklist The single decision that matters. A blocklist asks "is this name forbidden?" and must be re-derived every time any class in the hierarchy changes. An allowlist asks "is this name one of the ones I published?" and is stable under those changes. ```python ALLOWED = frozenset({"pick", "pack", "ship"}) if action not in ALLOWED: raise ValueError(f"unknown action: {action!r}") ``` The specific blocklist to reject in review is filtering by leading underscore. It stops `_internal_helper` and `__class__`, and leaves untouched every public method the object has, including ones inherited from a framework base class that nobody on the team has read. Underscore filtering is a signal that the author was thinking about the attributes they could remember, not the attributes that exist. The strongest version of the allowlist is a **registry built at import time**, because it makes the public surface a deliberate act: ```python ACTIONS = {} def action(fn): ACTIONS[fn.__name__] = fn return fn ``` Now a new method is private by default and becomes reachable only when someone types the decorator — a diff a reviewer will notice. ## 2. Narrow the target object `getattr(self, name)` inside a controller resolves against everything that controller is: database handles, configuration, credentials held as attributes, template helpers, and every method of every mixin. Even with a correct allowlist, that object is a large blast radius if the check is ever bypassed or a name is added carelessly. Resolve instead against a small object whose entire attribute surface is actions — a module of plain functions, a class with nothing but the handlers, or the registry dict itself. Then the worst case of a check failure is bounded by an object that holds nothing else. This is defence in depth: the allowlist is the control, the narrow target is what makes a mistake in the control survivable. ## 3. Validate the resolved object, not only the name After lookup, confirm what you got: it is callable, and where relevant it carries the marker your registration applied. Two real bugs this catches — an attribute that shadows a method with data, and a callable inherited from somewhere you did not intend. A signature check with `inspect` before calling with request-derived arguments is cheap insurance in a plugin system where the contract is not enforced by anything else. ## 4. The failure path Three habits that show production experience: * **Do not echo the rejected name** into an HTML response or an error page; treat it as untrusted data everywhere it travels, including into logs, where a newline in the name can forge log lines. * **Log rejections with enough context to alert on.** A spike of unknown-action errors from one client is reconnaissance, and it is often the earliest signal you get. * **Fail closed and identically.** An `AttributeError` escaping as a 500 with a traceback tells the caller which names exist; a uniform "unknown action" for both "forbidden" and "does not exist" tells them nothing. Timing differences between the two branches are rarely worth engineering around here, but the message should not differ. ## 5. What the review question is When you see `getattr` with a non-literal name in a diff, the question is not "is this sanitised?" but **"what is the complete set of names this can resolve, and who decides it?"** If the answer is "whatever attributes that object happens to have", it is a finding regardless of the filtering. If the answer is "these five, listed here", the pattern is fine. `setattr` gets the same question in the write direction, and `hasattr` deserves a look too, since it answers existence questions about the same surface without ever calling anything. One more construct belongs on that list. `operator.attrgetter` takes a name string and, notably, follows dots: a single attrgetter call built from an untrusted string can walk a chain of attributes rather than fetching one. Anything that resolves a *dotted* name from input — an attrgetter, a hand-rolled loop that splits on `.` and calls `getattr` repeatedly, a template engine given a raw path — has a far larger reachable set than a single lookup, because each hop lands on a new object with its own surface. If a dotted path really is the feature, resolve each segment against a per-level allowlist rather than validating the whole string once.
- Why is a registry built by a decorator better than a frozenset of names?Both are allowlists, but the registry keeps the name and the implementation together and makes registration the only way in, so a newly added method is unreachable until someone types the decorator. A separate name set can drift from the code it guards — a method gets renamed, the set is not updated, and the mismatch shows up as a broken action rather than as a security review. The registry also gives you the callable directly, so no attribute lookup happens at all.
- What should the error response say when the action name is rejected?A fixed, generic 'unknown action' with no echo of the submitted name and no traceback. Echoing the name pushes untrusted data into a response or a log line, and distinguishing 'forbidden' from 'does not exist' hands the caller a name oracle. Log the rejection server-side with the client identity so a burst from one source can be alerted on.
- Does checking the resolved object is callable add anything over the name allowlist?It is defence in depth for two concrete cases: an instance attribute shadowing a method with plain data, and a callable inherited from a base class you did not intend to expose. Neither is caught by the name check alone. In a plugin system it is also worth inspecting the signature before calling with request-derived arguments, since nothing else enforces that contract.
saying these in an interview costs you the question
- Filters names by rejecting a leading underscore
- Sanitises the name string rather than checking membership
- Dispatches against a controller holding connections and config
- Lets AttributeError surface as a traceback to the caller
- Echoes the rejected name back in the error response
- Assumes an allowlist of names makes the target object irrelevant