Secure File Access in Go: Path Traversal, Symlinks, and os.Root
TL;DR
File paths are not just strings.
A path represents a request to navigate a mutable filesystem namespace.
Many security vulnerabilities in file-handling code come from assuming:
That assumption is wrong.
In production Go systems:
filepath.Joindoes not create a security boundary.filepath.Cleanonly normalizes strings.filepath.IsLocalvalidates lexical properties, not filesystem behavior.Lstatdoes not eliminate symlink races.io/fs.ValidPathis not host filesystem protection.- Modern Go provides
os.Rootandos.OpenInRootfor root-confined filesystem access.
The key principle:
Validate paths at the same layer where they are used.
1. Why File Paths Are a Security Boundary
Many applications accept filenames from external users:
Examples:
- file upload services;
- document management systems;
- backup tools;
- archive extraction;
- artifact storage;
- web-based file browsers.
A typical implementation starts with:
It looks reasonable.
But what does it actually guarantee?
Only this:
It does not guarantee:
The filesystem can contain:
- symbolic links;
- renamed directories;
- mounted filesystems;
- concurrent modifications.
A path is not a static object.
2. Lexical Traversal vs Filesystem Traversal
Path traversal problems usually come in two forms.
They look similar but are fundamentally different.
2.1 Lexical Traversal
Example:
This is a property of the path string.
A simple normalization:
can remove redundant components.
Example:
becomes:
But this only operates on text.
It does not inspect the filesystem.
2.2 Filesystem Traversal
Example:
The path itself looks harmless:
but the filesystem resolves it elsewhere.
This is not a string problem.
It is a namespace problem.
3. filepath.Join Is Not Authorization
A common pattern:
The developer may think:
The file is inside
/srv/uploads.
But the operation only constructs:
It does not prove:
These are different questions.
A useful mental model:
The second question involves the operating system.
4. Why filepath.Clean Is Not Enough
Consider:
This protects against some obvious cases:
But not:
The problem:
is not the same as:
5. filepath.IsLocal Helps, But Only Lexically
Modern Go provides:
This is useful.
It rejects paths that are not local according to Go's path rules.
For example:
is not considered local.
But:
does not answer:
Can opening this path escape my intended directory?
It only answers:
Is this path string local?
The difference:
6. The Symlink Problem
Suppose:
Now:
The operating system follows the symbolic link.
The application may accidentally access:
instead of:
7. Why Lstat Does Not Fully Solve It
A common attempt:
It seems safer.
But there is a race.
The sequence:
Another process can change the filesystem between these operations.
Example:
This is another TOCTOU problem:
8. Avoid Manual Security Pipelines
Historically, developers often built something like:
The intention is good.
The problem:
each step observes the filesystem at a different moment.
Between steps:
Security decisions based on stale observations are fragile.
9. Go 1.26: Use Root-Confined Access
Modern Go provides a better abstraction.
Instead of:
use:
The important difference:
The operation is defined relative to a trusted root.
The application is no longer asking:
Can I safely construct an absolute path?
It is asking:
Open this name inside this filesystem root.
10. Using os.Root
For applications repeatedly accessing one directory:
Then:
The root becomes a managed resource.
Conceptually:
This matches the actual security requirement.
11. Example: Secure Upload Storage
Imagine:
A request contains:
Unsafe:
The program constructs a path.
It does not enforce containment.
Better:
The security boundary is now explicit.
12. os.Root Is Not a Sandbox
A common misunderstanding:
Does
os.Rootmake this directory a container?
No.
os.Root provides:
It does not provide:
It does not replace:
- containers;
- namespaces;
- virtual machines;
- operating-system security mechanisms.
A better model:
13. Mounts and Special Files Are Different Problems
Filesystem boundaries are complicated.
Examples:
- mount points;
- bind mounts;
- device files;
- kernel-provided filesystems.
A path staying inside a directory does not automatically mean:
every possible filesystem object behaves like a normal file.
Applications with strong isolation requirements need additional controls.
14. io/fs.ValidPath Solves a Different Problem
Go's io/fs package has:
It validates paths used by filesystem interfaces.
For example:
It does not mean:
This host filesystem operation cannot escape.
Different layers:
Do not mix them.
15. Archive Extraction Example
Archive extraction is a classic security problem.
Unsafe:
An archive entry:
can escape.
Even after cleaning:
symbolic links can create additional problems.
A safer design:
16. Deleting User-Controlled Paths
Deletion has even higher risk.
Dangerous:
Because a mistake can become:
For destructive operations:
- anchor the root;
- minimize permissions;
- avoid constructing paths from untrusted strings.
17. Production Checklist
Before accepting external filenames:
Path validation
Ask:
- Is this a path string problem?
- Or a filesystem traversal problem?
Symlinks
Ask:
- Can symbolic links exist?
- Can another process modify the directory?
APIs
Prefer:
when the requirement is:
stay inside this directory.
Avoid assuming:
Conclusion
Secure file access in Go is not about finding the perfect string manipulation function.
The filesystem is a dynamic namespace.
Between:
and:
the world can change.
The most reliable design is to express security requirements directly:
Modern Go provides the tools to make this boundary explicit.
Use them.