skip to content

An update endpoint is hardened with a deny-list of fields the binder must ignore; why does that decay in production, and what replaces it?

level: seniorimportance: should knowfreq 55%

answer

  1. the default for a field nobody considered
  2. fail open versus fail closed
  3. new field ships exposed
  4. the declared type is the list
  5. nesting and per-caller field sets still leak

basics

~20 s

A deny-list makes every field bindable by default, so each new domain field ships exposed until someone remembers to extend it. Replace it with an allow-list, ideally a transfer model, so new fields stay unbindable until deliberately added.

solid answer

~50 s

A deny-list is a negative statement about a set that keeps growing. It is correct on the day it is written and decays every time someone adds a field to the domain object, because the default for anything not named on the list is *bindable*. The failure is silent: no test fails, no error is logged, and the gap is discovered by an incident or an audit rather than by the pipeline. An allow-list inverts the default — nothing binds unless it is listed — so a new field is inert until someone deliberately exposes it, which is a reviewable change. The strongest form of an allow-list is a transfer model whose declared fields *are* the list, because it cannot drift away from the type it protects. Watch two gaps either way: nested objects need their own allow-list, and callers with different writable field sets need different models rather than conditional rules.

go deeper

for a junior

Remember the direction: list what may be sent, not what must be blocked. Anything you forget should end up not bound, rather than bound by accident.

for a middle

Explain the defaults. A deny-list makes each new field bindable from the commit that adds it, and the failure is silent because a bound field looks like any other.

for a senior

Show the migration: inventory what the endpoint really accepts, split client-settable from server-controlled, add the attack test first, and close the nesting and per-caller gaps.

for a principal

Argue from failure asymmetry and from where the control lives. A policy stored apart from the type it guards will drift; one expressed as a type cannot, and an automated architecture rule beats perpetual vigilance.

This is the classic **fail-open versus fail-closed** argument, applied to request binding. Both mechanisms restrict which incoming fields may be assigned; what separates them is the **default for a field nobody thought about**. ## Why the deny-list decays A deny-list says: bind everything except these. That means: - The list is correct only for the field set that existed when it was written. - Every field added to the domain object afterwards is bindable **by default**, from the commit that added it. - The person adding the field is usually not thinking about the binding surface. They are adding an internal flag, a review state, a computed total. - Nothing detects the gap. No exception, no failing test, no log line — a successfully bound field looks identical to a legitimate one. - The list lives somewhere other than the type it protects, so the two drift apart without any signal. The result is a control whose correctness depends on continuous human vigilance across everyone who will ever touch that domain object. That is not a control; it is a hope with a configuration file. ## What the allow-list changes | Property | Deny-list | Allow-list | |---|---|---| | Default for an unlisted field | bindable | not bound | | A new domain field is | exposed immediately | inert until listed | | Getting it wrong is | silent | visible, as a field that fails to update | | Who must remember | everyone touching the domain type | whoever adds the endpoint capability | | Review surface | the diff you did not write | the diff that grants the capability | The asymmetry of failure is the whole argument. When an allow-list is wrong, a field a client expected to set does not change, someone files a bug, and you fix it. When a deny-list is wrong, a client sets something they should not, and you learn about it from an incident. ## The strongest allow-list is a type A configured list of permitted names is an improvement, but it still lives apart from the object it guards, and nothing keeps the two in step. A **transfer model** collapses that distance: the fields it declares *are* the permitted set, they sit in one file, and a reviewer looking at the type is looking at the policy. Adding a capability means adding a field — visible, deliberate, and impossible to forget separately. ## Three gaps that survive the switch 1. **Nesting.** A transfer model constrains its own level. If it holds a nested type or a collection of them, binding recurses, and a domain type reachable at depth restores the exposure. Apply the same rule to every nested type, and be suspicious of any input model that reaches a domain type at any depth. 2. **Per-caller differences.** An administrator may set a `status` or `role` that an ordinary user may not. The tempting fix — one model with conditional rules about who may send what — puts an authorization decision inside binding configuration, where it is hard to read and easy to get wrong. Prefer separate models per caller class, or bind a permissive model and then run an explicit server-side check that this caller may write these fields, failing the request rather than silently dropping them. 3. **Shared models across endpoints.** One input model reused by an administrative endpoint and a public one carries the administrative fields to the public surface. Convenience today, exposure tomorrow. ## Migrating an existing endpoint 1. **Inventory what the endpoint actually accepts today** — the bound type's full settable field set, not the documentation, and not what the interface renders. 2. **Split it** into fields a client legitimately supplies and fields the server controls: identity, ownership, timestamps, version, computed money, state transitions. 3. **Introduce the transfer model** with the first group only, and map explicitly onto the domain object. 4. **Add the attack test** before the fix ships: post a payload containing a privileged field, read the record back, assert it is unchanged. Written this way, the test keeps its meaning through later refactors. 5. **Prevent the regression mechanically.** An architecture rule that no domain type appears in a handler's parameters, checked in the build, protects the endpoints nobody is reviewing. Silent failure classes need automated guards, not documentation. ## What to say when asked "is a deny-list ever acceptable?" As a short-lived stopgap on a surface you are about to replace, yes — it is better than nothing and can ship in an hour during an incident. What makes it indefensible is treating it as the finished control, because its correctness degrades with every unrelated commit, and it degrades quietly.

  • How would you prove the deny-list on an existing endpoint has already decayed?
    Enumerate the settable field set of the bound type, subtract the deny-list, and compare the remainder against what the endpoint is documented to accept. Anything left over is currently bindable. Then confirm it empirically: post each surplus field and read the record back. The gap is usually fields added after the list was written.
  • An administrator may set a status field that a regular user may not. How do you model that without conditional binding rules?
    Use two transfer models, or bind the permissive one and follow it with an explicit authorization check on the fields present, failing the request when the caller may not write them. Both keep the decision in readable code. Conditional rules inside binding configuration hide an authorization decision where reviewers do not look for one.
  • Why is an attack-shaped test better here than asserting the transfer model's field list?
    A field-list assertion tests the current implementation and breaks or passes for reasons unrelated to the exposure. An attack test states the property that matters — a privileged field sent by a client does not change the record — and keeps its meaning when the binding style, the model, or the mapping is rewritten later.
  • Does a permissive input model that silently drops unauthorized fields count as a fix?
    It closes the exposure but creates a debugging problem: a caller sending a field they may not set gets a success response and no change, which reads as a server bug. Prefer rejecting the request explicitly when a caller supplies a field they cannot write, so the contract stays honest in both directions.

A deny-list is a guest list of people barred from the building; anyone not on it walks in. An allow-list is a list of people admitted; everyone else waits outside until someone signs them in.

saying these in an interview costs you the question

  • A deny-list is equivalent as long as it is kept up to date
  • New domain fields will be noticed in review anyway
  • The top-level model is restricted, so nested objects are covered
  • One shared input model for admin and public endpoints is fine
  • Conditional binding rules per role are simpler than two models
  • No test failed after adding the field, so nothing is exposed