skip to content

The string `../../config/key.pem` is an ordinary key in an object store, a traversal when joined onto a filesystem path, and something different again as a URL path segment travelling through a reverse proxy. What general rule does that force, and where does it put the enforcement?

level: seniorimportance: should knowfreq 32%

answer

  1. safety = relation(name, resolver), not a string property
  2. five resolvers in series = five boundaries
  3. object-store key: `..` is a literal, no parent operator
  4. Win32 adds `\`, `C:file`, `\\?\`, trailing-dot stripping
  5. enforce at the sink, in the sink's grammar; edge filter = detection

basics

~20 s

Safety is a relation between a name and a specific resolver, not a property of the string. So "sanitised" cannot be carried across a boundary: each resolver in the chain is its own boundary, and enforcement belongs adjacent to each sink, expressed in that sink's grammar.

solid answer

~50 s

The rule: **a name is only dangerous relative to a grammar**, so safety is a relation between a name and a resolver, never an attribute of the string. Three divergences prove it. In an object store's key namespace there is no parent operator, so `..` is a literal component and the key is inert — it becomes an escape only when a downstream sync materialises keys onto a disk. Joined onto a POSIX path it is an operator; on Win32 the operator set is larger still, adding `\` as a separator, drive-relative `C:file`, and `\\?\` prefixes. As a URL path it is normalised by an intermediary whose rules need not match the origin's, so proxy and origin can disagree about which resource was requested. Consequently a shared `sanitizePath()` at the HTTP edge validates a language that is not the one the syscall speaks. Enforcement goes at each sink; the edge filter is a detection-rung control.

go deeper

for a junior

Know the headline: whether .. means anything depends on what resolves the name, so a check has to sit next to the thing that opens the file.

for a middle

Give two concrete resolvers that disagree about the same string and explain why a single edge-level sanitiser therefore cannot be the control.

for a senior

Trace a value through several resolvers, place the obligation at each sink, and explain why storing the raw name is correct while materialising it needs containment.

for a principal

Use the rule to set an architectural policy — no cross-boundary 'validated' path types, sink-local enforcement owned by the component that resolves — and to reason about compositional defects where two conforming components disagree.

## The observation Take one attacker-supplied string and follow it through a realistic request. It arrives percent-encoded in a URL, is decoded by a framework, stored as an object-store key, later fetched by a batch job, joined onto a working directory, and finally handed to a template loader. That is not one boundary. It is five resolvers in series, each with its own grammar, each deciding independently whether a given token is an operator or a literal. And they disagree. In the object store's key namespace there is no parent operator at all: `..` is an ordinary path-shaped component of an opaque key, and the string denotes exactly one blob, harmlessly. Joined onto a POSIX filesystem path it becomes an instruction to climb. On Win32 the operator set is strictly larger — `\` is also a separator, drive-relative names like `C:file` restart resolution somewhere else, `\\?\` and UNC prefixes change the parsing mode, and trailing dots and spaces are stripped so two names you believed were distinct address the same object. As a URL path it is subject to normalisation by whichever intermediaries are in the chain, and their rules — particularly around an encoded separator — need not match the origin's. ## The rule this forces **Safety is a relation between a name and a resolver, not a property of a string.** There is no such thing as a "clean path". There is only "this name, evaluated by that resolver, denotes an object inside the set I intended". The corollary is the part that changes architecture: *sanitised* is not an attribute that can be attached to a value and carried across a boundary. Every time the value crosses into a new resolver, the previous verdict expires, because it was a verdict about a different language. ## Where enforcement goes Adjacent to the sink, expressed in the sink's own grammar, on the post-resolution identity. Three consequences: **A shared edge sanitiser is structurally the wrong place.** A single `sanitizePath()` in an HTTP filter is validating a URL-shaped language against a threat that lives in a syscall-shaped language, several transformations later. It cannot see the join that has not happened yet, it cannot see symlinks in a directory that has not been chosen yet, and it cannot see the second decode. Keep it if you like — as a detection-rung control that produces a signal and rejects obvious junk — but do not let it be the reason the sink is safe. **Each resolver needs its own containment.** The extractor checks containment in its own destination; the file server checks containment in its own base; the router's decision must be made about the same decoded form the origin will use. Two correct checks in different grammars are not redundant; they are two different obligations. **Storing a hostile name can be safe, and that is not an accident.** Because a key namespace has no parent operator, keeping the raw name as data is fine. The danger is created at the moment of materialisation, which tells you exactly where the control belongs: at materialisation, not at ingest. A system that mangles names at ingest to make them "safe" has both corrupted its data and left the sink unprotected. ## The claim no single-stack question can make Each of these looks, from inside one stack, like local trivia: the Win32 separator quirk is a Windows footnote, the proxy disagreement is an HTTP footnote, the object-store inertness is a storage footnote. Put together they yield something stronger than the sum: **the danger is not in the string and therefore cannot be decided where the string enters.** Any design that concentrates path defence at the perimeter is making a category error, and any design that hands a "validated path" object across module boundaries is encoding a verdict that stops being true at the next boundary. It also explains a failure that otherwise looks like bad luck: two components each behaving correctly by their own specification can compose into a vulnerability, because the vulnerability lives in the *disagreement*, not in either component. When a front-end normalises before making an authorisation decision and a back-end resolves differently afterwards, neither is buggy in isolation. The fix is not to find the guilty component; it is to make the authorisation decision about the same resolved identity the back-end will act on, or to remove the second resolution entirely. ## How this relates to the neighbouring sinks The abstract shape is shared with the other injection classes — data crossing into a position where an evaluator treats it as instructions — and the reference instance for that shape is SQL, where binding separates template from value. What travels here is only the shape. The operator sets do not transfer, and a control built for one sink is inert at another. That is precisely why the rule is per-sink rather than per-input, and why an interviewer who hears "we sanitise all user input centrally" will keep pulling on the thread.

  • If a raw hostile name is safe to store as an object key, should you still normalise it on the way in?
    Normalise for your own data-quality reasons if you like, but never as the security control, and never in a way that loses information. The escape is created at materialisation — when a key becomes a filesystem path — so that is where containment must be enforced. Cleaning at ingest gives you a corrupted value and an unprotected sink, which is the worst of both.
  • Two components each conform to their own specification and yet compose into a traversal. What does that tell you about the fix?
    That the defect lives in the disagreement rather than in either component, so hunting for the guilty one will not converge. The durable fixes are to make the authorisation decision about the identity the downstream component will actually resolve, or to eliminate the second resolution — for example by having the front-end pass an already-resolved reference rather than a name to be re-interpreted.

saying these in an interview costs you the question

  • "We sanitise all user input centrally at the edge" — the edge speaks a different naming language than the sink and cannot see later joins, decodes or symlinks.
  • Passing a "validated path" value across module boundaries as though the verdict travelled with it.
  • Assuming an object-store key containing `..` is itself a vulnerability, rather than something that becomes one at materialisation.
  • Treating a proxy/origin normalisation disagreement as a bug in one of the two rather than as a property of the composition.

context