Skip to content

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 DirectorySource over a user override directory legitimately holds almost none of the paths asked of it, and list_dir may 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.