A service must return files from a directory, and part of the file's name arrives in the request. Rank the available defences from strongest to weakest, say what each one actually guarantees, and state what a canonicalise-then-compare-the-prefix implementation is still assuming.
answer
- structural separation > escaping/transformation > validation > detection
- enumeration takes rung 1 when separation is unavailable
- closed-world map vs open-world pattern
- resolve-beneath / jail = escape inexpressible
- prefix test: component-wise, symlinks resolved, no change before use
basics
~20 sStrongest first: structural separation (a resolver confined to the base); then closed-world enumeration (an id mapped to a code-owned name); then transformation (canonicalise, then containment-test the resolved path); then validation; then detection. Prefix checks assume component-wise comparison, symlinks resolved, and nothing changing before use.
solid answer
~50 sUse one ordering: **structural separation > escaping/transformation > validation > detection**, weakening from guarantee to heuristic at each step. Where structural separation is unavailable, **closed-world enumeration** takes the top rung. 1. *Structural separation:* constrain the resolver so escape is inexpressible — open relative to a directory handle with a resolve-beneath constraint, a jail or mount namespace, or a storage API whose namespace has no parent operator. The guarantee holds against a name you never inspected. 2. *Closed-world enumeration:* the request carries an opaque id; code maps it to a name it owns. Finite and owned, versus a character allow-list, which is open-world and still admits every real filename including your key material. 3. *Transformation:* canonicalise fully, then containment-test the resolved absolute path. 4. *Validation:* reject `..` — a heuristic over an operator set you do not define. 5. *Detection:* logging and alerting; no guarantee. Canonicalise-then-check assumes the comparison is **component-wise** (`/srv/files-backup` is not under `/srv/files`), that symlinks were resolved, and that nothing changes between check and use.
code
text · 5 linesbase = /srv/files
resolved = /srv/files-backup/secret.pem
startsWith(resolved, base) -> true (wrong answer)
componentsUnder(resolved, base) -> false (right answer)go deeper
Recite the ladder in order and know that resolving the path and then checking it stays under the base is the common correct implementation, while stripping .. is not.
Explain what each rung guarantees, and name at least two assumptions the canonicalise-then-check approach makes — the component-wise comparison and full symlink resolution are the ones to have ready.
Argue the ordering from open-world versus closed-world and from capability-removal versus escape-detection, and walk the full assumption list of the rung the team actually runs.
Decide where in the platform the guarantee should live — a resolver-confined I/O layer or a namespace boundary that every service inherits — and be explicit that a general file feature is a decision to operate permanently on a detecting control.
## One ladder, one vocabulary The defence ordering for any injection-shaped class is **structural separation > escaping/transformation > validation > detection**, and each step down trades a guarantee for a heuristic. Path traversal has one wrinkle worth stating up front: when structural separation is not available, **closed-world enumeration** occupies the top rung in its place. The reason is open-world versus closed-world. A pattern that constrains characters is open-world — it still admits every real name the resolver can reach, including the sensitive ones, because `secrets/signing.pem` contains nothing a character allow-list objects to. An enumerated map is finite and owned by your code, so the set of reachable objects is a thing you wrote down rather than a thing you hoped to have excluded. ## Rung 1 — structural separation Make the escape inexpressible in the resolver rather than absent from the string. Concretely: open the target relative to an already-opened directory handle with a resolve-beneath constraint so the kernel itself refuses any component that leaves the base; run the operation in a mount namespace or jail whose root *is* the base; or use a storage namespace where no parent operator exists at all, so `..` is a literal component of a key and denotes nothing. What this guarantees is qualitatively different from everything below it: it holds for a name you never inspected, for encodings you never anticipated, and for symlinks planted after you looked. This is the analogue of separating a query template from its values — the hostile content is still hostile, but the channel it arrives on cannot express the operation the attacker wants. The cost is real: it needs platform support, it does not span every resolver you use, and jails and namespaces are operational surface. That is why the rung below it exists. ## Rung 2 — closed-world enumeration The request carries an opaque identifier; a code-owned table maps it to a name. For a documents feature this is a database row; for a static asset set it is a generated manifest. The user's bytes never reach the resolver, which is why this sits at the top when structural separation is unavailable — it is the same idea reached from the other side. Its limit is that it requires the reachable set to be finite and knowable; a general file browser cannot use it, and a per-user namespace needs the map to be scoped per principal or you have solved traversal and left an authorisation hole. ## Rung 3 — transformation and containment Canonicalise the joined path fully, then verify the *result* lies under the base. This is the workhorse, and it is genuinely weaker than the two rungs above for a specific reason: it does not remove the attacker's ability to express the escape, it only detects that they did. Detection depends on modelling the resolver correctly, and every assumption you get wrong is a bypass. What it assumes, precisely: - **Component-wise comparison, not string prefix.** `/srv/files-backup/secret.pem` passes a naive `startsWith("/srv/files")` and is not under `/srv/files`. Compare resolved components, or compare against the base with a trailing separator. - **Symlinks actually resolved.** A purely lexical normalisation that collapses `a/b/../c` textually never consults the namespace, so a symlinked component still escapes. Lexical normalisation and full resolution are different operations and only the latter answers the question you are asking. - **Nothing changes between check and use.** Verification and operation are two visits to a mutable shared namespace. If the parent directory is writable by anyone hostile, the object can be swapped in between. - **You canonicalise the same string you will use.** Any further decode, join, case fold or normalisation after the check invalidates it. - **The base itself is stable.** If the base is reached through a symlink that someone else can alter, containment is defined against a moving target. ## Rung 4 — validation Rejecting names that contain `..`, or requiring names to match a conservative character class. This is a heuristic over an operator set you do not define: it does not see symlinks, it is applied before later transformations, and it can be defeated by an alternate separator or an extra decoding layer that the resolver honours and your filter did not. It has real value as a cheap early reject and as a signal, and none as a guarantee. Note also that the general discipline of validating at a trust boundary is a broader topic in its own right; here the point is narrower — the rung's *position*, and why it is below transformation. ## Rung 5 — detection Alerting on rejected names, on resolved paths outside the base, on unusual read volume. It catches what got through and shortens the incident, and it guarantees nothing about any individual request. Deploy it, never count it. ## Answering well Give the ladder, give the reason for at least one adjacent pair (structural separation removes the capability while transformation only detects its use; enumeration is closed-world while validation is open-world), then volunteer the assumption list for the rung most teams actually ship. That last part is what distinguishes someone who has fixed this from someone who has read about it.
- Why is canonicalise-then-check ranked below closed-world enumeration when it appears to be a stronger, more general control?Because it is a detection of the escape rather than a removal of the capability, and its correctness rests on modelling a resolver you do not own. Enumeration makes the reachable set finite and code-owned, so there is nothing to model: the worst case is the caller reaching a different entry in a table you wrote. Generality is the reason to reach for containment, not evidence that it is stronger.
- You are told the platform offers no resolve-beneath primitive and the file set is genuinely unbounded. What do you ship?Rung 3 done carefully, with the assumption list closed one by one: resolve fully rather than normalising lexically, compare components against a base captured once at startup, keep the base directory and its parents non-writable by any principal the attacker can influence, and make the operation act on a handle obtained during the check rather than re-opening by name. Then add rung 4 as an early reject and rung 5 for visibility, and record that you are relying on a control that detects rather than prevents.
saying these in an interview costs you the question
- Using `startsWith` on the base directory string, which passes `/srv/files-backup` as if it were inside `/srv/files`.
- Treating lexical normalisation as canonicalisation — collapsing `..` textually never consults the namespace, so symlinked components still escape.
- Calling a character allow-list the strongest defence; it is open-world and still admits every sensitive real filename.
- Ranking a web application firewall rule alongside prevention rather than at the detection rung.
- Assuming containment implies authorisation, so any object under the base may be served to any caller.