Kyber Cypher

Learn

Field Log 024

Write the guard before the thing that needs it

Field Log // 024 Status Live Difficulty Free Cost Nothing
The Story

I needed a set of tools that could read and write files on my behalf. Useful, and obviously dangerous, because a tool that can open any path you name is a tool that can open the wrong path when you name it carelessly.

So I did something that felt backwards and turned out to be the single best decision in the project. I wrote the safety check first. Before a single tool existed, there was a function whose only job was to answer one question: is this path inside the directory I allowed, or not. Twenty tests. Then, and only then, the tools.

Every time I had done this the other way around, the guard arrived late and arrived as a guess. You write the feature, it works, you ship it, and then at some point you think about the sharp edges and bolt on a check shaped like the mistakes you happened to imagine that afternoon. It catches those. It misses the ones you did not think of, which are the ones that matter, because the mistakes you can imagine are the mistakes you were already avoiding.

A handrail added after someone falls is placed where that person fell. A handrail designed with the staircase runs the whole way down.

Writing it first changes what you are doing. With no feature to protect, you stop asking "what could go wrong here" and start asking "what does safe actually mean". Those produce different code. The first produces a list of patches. The second produces a contract: a single place that decides, that everything else must pass through, that you can test to exhaustion in isolation because it has no dependencies to mock.

The tests are the part people skip, and they are the reason it works. Twenty of them sounds like a lot for one function. It took about twenty minutes, because each one is three lines, and they let me write the rest of the tools without ever again thinking about path safety. I had already thought about it. Once, properly, with my full attention, while it was the only thing on the table.

The honest catch is that a guard written first can still be the wrong guard. Writing it early buys you focus, not omniscience. What it does buy is a single place to fix when you learn something new, instead of eleven call sites.

The Build

The generic pattern for a path guard, and the tests that prove it. Written in the order I actually wrote it: the test list, then the guard, then the tools. Nothing here is specific to one language; the shape is what matters.

1. Write the list of things that must be refused, before any code

Plain sentences, in a file. This takes ten minutes and it is the design.

# guard.md
# ALLOW: a path that resolves inside BASE
# REFUSE: ..  anywhere in the path (traversal)
# REFUSE: an absolute path pointing outside BASE
# REFUSE: a symlink whose target leaves BASE
# REFUSE: encoded traversal (%2e%2e, double encoding)
# REFUSE: a null byte or newline in the path
# REFUSE: BASE itself where a file is required
# DECIDE: case sensitivity, and say so out loud

That last line is not padding. On a case insensitive filesystem a check that compares strings will disagree with the filesystem, and you need to know which one you are trusting.

2. Resolve first, compare second

Every path guard that gets broken was broken because it inspected the string the caller sent instead of the location that string actually points to. Resolve symlinks and relative segments down to a real absolute path, then ask whether that real path is inside your base.

# the shape, in any language
base = resolve(BASE)                 # once, at startup
target = resolve(join(base, user_input))

# containment check on the RESOLVED paths, never on the raw input
if not is_inside(target, base):
    refuse()

Use your language's real path resolution rather than string handling. In Python that is Path.resolve(), in Node fs.realpathSync, in Go filepath.EvalSymlinks. String manipulation will get the easy cases right and the interesting ones wrong.

3. Make the containment check a path comparison, not a prefix match

This is the classic error. Comparing text prefixes says /data-secret is inside /data, because the characters line up. Compare path components instead.

# WRONG: a string prefix match
#   target.startswith(base)   ->  /data-secret passes as inside /data

# RIGHT: ask the path library whether one contains the other
#   Python:  base in target.parents
#   Node:    path.relative(base, target) does not start with '..' and is not absolute

4. Refuse by default, and return one answer

The guard should have exactly one success path and one failure answer. Anything unexpected, including an error while resolving, is a refusal. A guard that returns the input on an internal error is not a guard.

# one entry point, used by every tool, no exceptions and no second version
safe_path(user_input) -> resolved path  |  refuse

# and refuse without echoing the path back in the error, because the error
# message is an information leak of its own

5. Write the tests from the list in step one

One test per line of that file, plus the ones you discover while writing it. Each should be short enough to read in a second.

# traversal
refuse("../outside.txt")
refuse("a/../../outside.txt")
refuse("....//outside.txt")

# absolute escape
refuse("/etc/hosts")

# encoded
refuse("%2e%2e/outside.txt")
refuse("..%2foutside.txt")

# control characters
refuse("ok.txt\0.png")
refuse("ok\n.txt")

# the sibling-prefix trap from step three
refuse_outside_base("<base>-secret/file.txt")

# symlink pointing out of the base, created inside the test
refuse_symlink_escaping_base()

# and the ones that must WORK, or you have written a brick
allow("notes.txt")
allow("sub/dir/notes.txt")
allow("./notes.txt")
allow("name with spaces.txt")
allow("unicode-ok.txt")

Include the allow cases. A guard that refuses everything passes every refusal test and is useless, and that failure is easy to ship if you only ever test the dangerous side.

6. Only now write the tools, and make the guard unavoidable

Each tool takes its path, calls the guard, and uses the value the guard returns rather than the value the caller sent. If a tool can reach the filesystem without passing through the guard, the guard is advice.

# every tool, same two lines at the top
p = safe_path(requested)      # refuses on its own if unsafe
open(p)                       # use the RESOLVED path, never `requested`

Then run the whole test file on every change, including changes that have nothing to do with paths. The point of a contract is that it keeps holding while you are thinking about something else.

Related: auditing what your own services expose is where this guard earns its keep, because authentication decides who may ask and the guard decides what can be served.