Why path traversal is rejected lexically¶
The threat being defended against¶
Asset paths are frequently attacker-influenced. A template name comes
from a config file, a docs page comes from a URL fragment, a theme name
comes from a command-line flag. If any of those reaches a
DirectorySource unchecked, ../../etc/passwd reads a file the tool
had no business reading, and the process happily returns its bytes to
whoever asked.
DirectorySource therefore refuses to resolve any path that could
leave its root. Every path goes through one function before a
filesystem call happens.
What the check does¶
The rule is deliberately blunt. Walking the requested path component by component:
- an absolute path is rejected outright,
- a
..component is rejected, - a Windows prefix component such as
C:is rejected, - a
.component is skipped, - anything else extends the path under the root.
If any rejection fires, the whole lookup returns None and no
filesystem call is made. list applies the same rule and returns an
empty listing.
It is worth being clear that .. is refused anywhere, not only
where it would escape. sub/../allowed.txt stays inside the root and
is still rejected. That is stricter than necessary, and it is the right
kind of strict: a rule with no exceptions is one that can be read off
the code in a second, and asset paths never need .. anyway.
Why lexical, and not canonicalize()¶
std::fs::canonicalize resolves symlinks and gives a real answer about
where a path truly points. It also requires the path to exist. Two
consequences ruled it out:
- Assets are frequently absent. A
DirectorySourceover a user override directory legitimately holds almost none of the paths asked of it, andlist_dirmay be called on a directory that does not exist on that layer. A check that errors on non-existence would turn the normal case into a failure. - Every lookup would cost syscalls. The lexical check is string
work and runs before touching the disk. A read of a shadowed path
across four layers does four checks, and three of them should not
cost a
stat.
The lexical check is complete against the class of attack it targets:
you cannot express "outside this root" in a relative path without a
.., an absolute path, or a drive prefix, and all three are refused.
What this does not protect against¶
Be precise about the boundary, because a security control that is believed to do more than it does is worse than one that is understood.
Symlinks are followed. The check never touches the filesystem, so
it cannot know that theme.yaml inside the root is a symlink to
/etc/shadow. std::fs::read follows it and returns the bytes. If the
root directory is writable by anyone you do not trust, a symlink placed
there defeats the boundary entirely. Following symlinks is a caller
concern: choose roots that only the user or the administrator can
write.
There is no time-of-check/time-of-use guarantee. The path is validated, then read. Anything that can modify the directory between those two moments can change what is read.
Hard links are invisible. Same reasoning as symlinks, with no marker to notice even if you did check.
Nothing is protected on the other source types. MemorySource and
EmbeddedSource hold their own content and have no filesystem to
escape to, so they apply no path rules at all — they simply fail to
match a key with a .. in it. The protection is a property of
DirectorySource, not of Assets.
Rejection is silent, and that is a trade¶
A rejected path returns None, exactly like a file that is not there.
open_text reports NotFound. Nothing logs, nothing warns, and no
distinct error variant exists.
The argument for it: the lower layers of the overlay should still get their turn, and a security rule that returns a distinguishable error tells a prober which of their inputs got further than the others.
The argument against it: an operator debugging a legitimately odd path gets no signal that a rule fired, and will look at file permissions before looking at the path string. If your tool accepts asset paths from users, validate and report them at your own boundary, where you can give a useful message, and treat this check as the backstop rather than the front door.