Build the safety check before the thing that needs it
What you will learn
- Why a safety check written afterwards only catches the mistakes you already imagined
- How to write the check first, and what changes when you do
- The twenty small tests that let you stop thinking about the problem forever
I needed a set of tools that could read and write files on my behalf. Useful, and obviously dangerous, because a tool that will open any file you name is a tool that will open the wrong file when you name it carelessly.
So I did something that felt backwards and turned out to be the best decision in the whole project. I wrote the safety check first. Before a single tool existed, there was one small piece of code whose only job was to answer one question: is this file inside the folder I allowed, or not. Twenty tests for it. Then, and only then, the tools.
Every time I had done this the other way round, the check arrived late and arrived as a guess. You write the useful thing, it works, you start using it, and then at some point you think about the sharp edges and bolt on a check shaped like whichever mistakes you happened to imagine that afternoon. It catches those. It misses the ones you did not think of, and those are the ones that matter, because the mistakes you can imagine are the mistakes you were already avoiding.
A handrail added after somebody falls gets put where that person fell. A handrail designed along with the staircase runs the whole way down.
Writing it first changes the question you are answering
This is the part I did not expect. With no useful thing to protect yet, you stop asking "what could go wrong here" and start asking "what does safe actually mean". Those two questions produce different code.
The first produces a list of patches. The second produces a promise: one single place that decides, which everything else has to pass through, and which you can test to death on its own because it does not depend on anything.
The tests are the part people skip, and they are why it works
Twenty tests for one small piece of code sounds like a lot. It took about twenty minutes, because each test is three lines. And they let me write every tool afterwards without ever thinking about file safety again. I had already thought about it. Once, properly, with my full attention, while it was the only thing on the table.
A check written first can still be the wrong check. Writing it early buys you focus, not foresight. What it does buy is one place to fix when you learn something new, instead of eleven places that each need finding.
A handrail added after somebody falls gets put where that person fell.
This is the general shape, written in the order I actually wrote it: the list of refusals, then the check, then the tools. Nothing here belongs to one programming language. The shape is the point.
1. Write the list of things that must be refused, before any code
What this does: turns the safety question into plain sentences in a file.
Why it comes first: this takes ten minutes and it is the design. Everything after it is typing.
# guard.md
# ALLOW: a file that really sits inside the folder I chose
# REFUSE: anything using ".." to climb out of it
# REFUSE: a full path pointing somewhere else entirely
# REFUSE: a shortcut whose real target is outside the folder
# REFUSE: a disguised version of ".." written in web encoding
# REFUSE: hidden control characters inside the name
# REFUSE: the folder itself, where a file is required
# DECIDE: do upper and lower case count as the same name? say so.
That last line is not padding: on some computers two names that differ only in capital letters are the same file, and on others they are not. A check that compares the text will disagree with the computer, and you need to know which one you are trusting.
2. Work out where the file really is, then compare
What this does: follows shortcuts and tidies up the path until you have the file's real location, and only then asks whether that location is inside your folder.
Why this ordering is everything: every check of this kind that has ever been broken was broken because it inspected the text somebody sent instead of the place that text actually points to.
# the shape, in any language
base = resolve(BASE) # once, at startup
target = resolve(join(base, what_was_asked_for))
# compare the REAL locations, never the raw request
if not is_inside(target, base):
refuse()
Use the tool your language already has for working out a real location, rather than chopping up text yourself. Text handling gets the easy cases right and the interesting ones wrong.
3. Compare folders, not the start of a word
What this does: asks the path tools whether one location sits inside another.
Why the obvious version fails: comparing the beginning of the text says that a folder called data-secret is inside a folder called data, because the letters line up. They are completely different folders. This is the classic mistake.
# WRONG: comparing the start of the text
# data-secret passes as being inside data
# RIGHT: ask the path tools whether one contains the other
# Python: base in target.parents
# Node: path.relative(base, target) does not begin with ".."
4. Refuse by default, and give exactly one answer
What this does: gives the check one way to succeed and one way to refuse.
Including when something unexpected happens: if the check itself runs into trouble working out where a file is, that is a refusal too. A check that hands back the original request when it gets confused is not a check.
And do not repeat the request back: an error message that quotes the path tells a stranger what does and does not exist on your machine.
# one entry point, used by every tool, no exceptions and no second copy
safe_path(what_was_asked_for) -> a real location, or a refusal
5. Write the tests from the list in step one
What this does: turns each line of your refusal list into one short test, plus the extra cases you discover while writing them.
Why they must include the allowed cases: a check that refuses everything passes every refusal test and is useless. That is an easy mistake to ship if you only ever test the dangerous side.
# climbing out
refuse("../outside.txt")
refuse("a/../../outside.txt")
# pointing somewhere else entirely
refuse("/etc/hosts")
# disguised, written in web encoding
refuse("%2e%2e/outside.txt")
# hidden control characters
refuse("ok.txt\0.png")
# the lookalike-folder trap from step three
refuse_outside_base("<base>-secret/file.txt")
# a shortcut leading out, created inside the test itself
refuse_shortcut_escaping_base()
# and the ones that must WORK, or you have built a brick
allow("notes.txt")
allow("sub/dir/notes.txt")
allow("name with spaces.txt")
6. Only now write the tools, and make the check unavoidable
What this does: has every tool hand its request to the check first, and then use the location the check hands back rather than the one it was given.
Why that second half matters: if a tool can reach a file without going through the check, the check is advice.
# every tool, the same two lines at the top
p = safe_path(requested) # refuses on its own if it is not allowed
open(p) # use what the check returned, never `requested`
Run the whole test file on every change, including changes that have nothing to do with files. The point of a promise is that it keeps holding while your attention is elsewhere.
Related reading: checking what your own programs are offering the house is where this check earns its keep, because demanding a secret decides who may ask and this check decides what can be handed over.
This page in the original Kyber Cypher voice: Write the guard before the thing that needs it