skip to content

A filename check passes, and yet the file that ends up being opened is not the file that was checked. Enumerate the mechanisms that let the string a validator inspects differ from the name a resolver acts on, and give the rule that eliminates the whole class.

level: principalimportance: nice to knowfreq 26%

answer

  1. T1 checks a string, T2 resolves a string — three ways they diverge
  2. decode count is part of the contract
  3. normalisation/case-fold/trailing-dot runs after your decision
  4. NUL or length truncation removes the suffix the check relied on
  5. resolve once → verify the handle → operate on the handle

basics

~20 s

Four mechanisms: another decode pass after the check, normalisation or platform mangling applied later, truncation at a layer boundary such as an embedded NUL, and re-resolution over a mutable namespace. The rule: resolve once to a handle, verify the handle, operate on the handle.

solid answer

~60 s

Check-versus-use divergence has four sources. 1. **Layered decoding.** Each decode can reintroduce an operator: an encoded separator that a second pass turns into a real one, a doubly-encoded sequence that survives one decode, historically overlong UTF-8 forms accepted by lenient decoders. 2. **Later normalisation or platform mangling.** Compatibility normalisation can fold an exotic character into a plain separator; case folding merges names; some resolvers strip trailing dots and spaces or honour short-name aliases. 3. **Truncation at a boundary.** A length-prefixed string handed to an API that terminates at NUL loses everything after the NUL, so a suffix the check relied on disappears. Length limits truncate the same way. 4. **Re-resolution.** The name is unchanged and the namespace moved — a component replaced by a symlink between check and open. The rule: eliminate the second resolution. Resolve once to a handle, verify that handle's identity, and operate on the handle so no name is interpreted twice. Where the platform can confine the resolver, there is nothing to verify at all.

go deeper

for a junior

Know that a check and an open are two different moments, and that anything happening in between — another decode, a normalisation, a truncation — can change which file is opened.

for a middle

Name the four mechanisms and give an example of each, and explain why a suffix-based check is fragile.

for a senior

Show that all four depend on a second resolution, and implement the fix: single decode point, resolve once, act on the reference the check produced.

for a principal

State it as a general check-then-act rule over shared mutable state, choose between resolver confinement and handle-based operation for the platform at hand, and set the decode-ownership contract across services so no component ever transforms a value another component has already blessed.

## The shape of the problem Every name-based check has the same structure: at time T1 you inspect a string, and at time T2 something resolves a string to an object. Soundness requires that the string at T2 is the same string, that it is interpreted by the same rules, and that the namespace has not changed. Each of those three can fail independently, which is why this class keeps reappearing in code whose validation looks correct. ## Mechanism 1 — layered decoding Any transformation between T1 and T2 can put an operator back into a string that had none. Percent-decoding is the common case: an encoded separator is inert to a filter that runs before the decode and is a separator afterwards. Double encoding survives one decode pass and yields an encoded form to the next, so a system with two decoders — an intermediary and an application, or a framework and a hand-rolled helper — has a window one decoder wide. Historically, lenient UTF-8 decoders accepted overlong encodings of ASCII characters, so a multi-byte sequence that no filter recognised decoded into a plain separator; strict decoders reject these now, which is exactly why "how many decoders and how strict is each" is a design question rather than a library question. The underlying rule is that **the number of decodes is part of your contract**. Decode once, at a place you name, and treat any component that decodes again as a defect. ## Mechanism 2 — normalisation and platform mangling applied after the check Unicode compatibility normalisation can map exotic characters onto plain ASCII ones, so a name that contained no separator when checked contains one when normalised. Case folding merges names that the check treated as distinct — relevant wherever a case-insensitive resolver sits behind a case-sensitive check. Some resolvers strip trailing dots and spaces, so `secret.txt.` and `secret.txt` address one object while comparing as two strings. Legacy short-name aliases give a second name for the same object that no allow-list of long names anticipated, and alternate data-stream syntax appends a suffix that changes which byte range is opened without changing which file is named. The pattern is identical to mechanism 1: a transformation you did not model runs after your decision. ## Mechanism 3 — truncation at a layer boundary When a length-prefixed string crosses into an interface that terminates strings at a NUL byte, everything after the first NUL disappears. A check that relies on a suffix — "the name must end in `.png`" — sees the suffix, and the resolver sees a name that ended before it. Modern managed runtimes generally reject embedded NULs in path arguments, which retired this as a routine finding, but it persists wherever a length-prefixed string reaches a NUL-terminated interface: native extensions, foreign-function bridges, older runtimes, and protocol parsers that hand raw bytes onward. Ordinary length truncation is the same defect without the exotic byte: a resolver or an intermediate buffer that silently caps a name drops the tail the check depended on. The general lesson is that **a check that depends on a suffix is a check that depends on nothing being able to remove the suffix**, which is a strong assumption about every layer downstream. ## Mechanism 4 — re-resolution over a mutable namespace Here nothing about the string changes. The check resolved the name to object X; between the check and the operation, some other principal replaced a component of the path with a link, and the operation resolves the same name to object Y. The namespace is shared, mutable, and not transactional, so a verdict about it is a statement about the past. This is the mechanism that cannot be fixed by being more careful with strings, and it is why the other three are ultimately a symptom rather than the disease. ## The rule that eliminates the class **Stop deciding about names, and stop resolving twice.** Operationally: perform the resolution once and obtain a *handle* — a descriptor or reference bound to the object, not to the name. Verify the handle's identity, and then perform the operation on the handle. Because the handle no longer refers to a name, there is no second resolution for a decoder, a normaliser, a truncator or a racing attacker to alter. Every one of the four mechanisms depends on a second interpretation happening, so removing the second interpretation removes all four at once — which is the sign of a real fix rather than four patches. Stronger still, where the platform allows it: confine the resolver so escape is inexpressible — open relative to a directory handle with a resolve-beneath constraint, or resolve inside a namespace rooted at the base. Then there is no verification step to race, because there is no verdict. And where neither is available, the fallback is a discipline, not a filter: decode exactly once at a named owner; canonicalise the same value you will pass onward; never validate a string that a later stage will transform; never rely on a suffix; and keep the parent chain of the base directory unwritable by any principal you do not trust, so mechanism 4 has no lever. ## Why this is the principal-level version of the topic Juniors learn to reject `..`; seniors learn to resolve and containment-test. The step beyond is recognising that resolve-and-check is still two operations over shared mutable state, and that the durable design move is to collapse them into one — to hold a reference rather than to re-derive it. That reasoning transfers directly to every other check-then-act boundary in a system, which is why it is worth being able to state as a rule rather than as a list of tricks.

  • Handles are not available for every resolver — a URL router or an object-store client has no descriptor. What is the equivalent move there?
    Carry forward a resolved, code-owned reference instead of a name: an internal identifier the downstream component uses directly, so it never re-interprets a caller-supplied string. Where a component genuinely must re-parse, make the two parsers the same implementation with the same settings, and make the authorisation decision about the output of that parse rather than its input.
  • Why is 'canonicalise, then check, then open by name' unsound even when the canonicalisation is perfect?
    Because check and open are two separate resolutions over a mutable shared namespace. A perfect canonicalisation tells you what the name meant at the moment you asked; a component of the path can be replaced by a link before the open, and the same name then denotes a different object. The gap is structural, so the fix is to operate on the reference the check produced rather than to re-open by name.

saying these in an interview costs you the question

  • Treating this as a list of encoding tricks to blacklist rather than as a structural check-then-act gap.
  • Validating a value that a later stage will decode, join or normalise again, and assuming the verdict survives.
  • Relying on a filename suffix as a security check, which any truncation downstream can remove.
  • Believing a correct canonicalisation makes a later open-by-name safe, ignoring that the namespace is mutable between the two operations.
  • Adding a second decode 'to be safe', which creates exactly the window a double-encoded input needs.

context