• English
  • 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:

    "path looks safe"
            =
    "filesystem access is safe"

    That assumption is wrong.

    In production Go systems:

    • filepath.Join does not create a security boundary.
    • filepath.Clean only normalizes strings.
    • filepath.IsLocal validates lexical properties, not filesystem behavior.
    • Lstat does not eliminate symlink races.
    • io/fs.ValidPath is not host filesystem protection.
    • Modern Go provides os.Root and os.OpenInRoot for 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:

    path := filepath.Join(uploadDir, userInput)
    
    f, err := os.Open(path)

    It looks reasonable.

    But what does it actually guarantee?

    Only this:

    combine two strings

    It does not guarantee:

    the final filesystem object stays inside uploadDir

    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:

    ../../etc/passwd

    This is a property of the path string.

    A simple normalization:

    clean := filepath.Clean(name)

    can remove redundant components.

    Example:

    a/../b

    becomes:

    b

    But this only operates on text.

    It does not inspect the filesystem.


    2.2 Filesystem Traversal

    Example:

    uploads/
        avatar.png -> /etc/passwd

    The path itself looks harmless:

    uploads/avatar.png

    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:

    path := filepath.Join(
        "/srv/uploads",
        filename,
    )
    
    os.Open(path)

    The developer may think:

    The file is inside /srv/uploads.

    But the operation only constructs:

    /srv/uploads/<filename>

    It does not prove:

    resolved filesystem object
            |
            v
    inside /srv/uploads

    These are different questions.

    A useful mental model:

    filepath.Join
    
        answers:
    
        "What string should I create?"
    
    
    
    os.Open
    
        answers:
    
        "What filesystem object does this path resolve to?"

    The second question involves the operating system.


    4. Why filepath.Clean Is Not Enough

    Consider:

    name := filepath.Clean(userInput)
    
    path := filepath.Join(root, name)
    
    os.Open(path)

    This protects against some obvious cases:

    ../../secret.txt

    But not:

    uploads/
        public/
            latest -> /private

    The problem:

    string normalization

    is not the same as:

    filesystem confinement

    5. filepath.IsLocal Helps, But Only Lexically

    Modern Go provides:

    if !filepath.IsLocal(name) {
        return errors.New("invalid path")
    }

    This is useful.

    It rejects paths that are not local according to Go's path rules.

    For example:

    ../config.yaml

    is not considered local.

    But:

    filepath.IsLocal(name)

    does not answer:

    Can opening this path escape my intended directory?

    It only answers:

    Is this path string local?

    The difference:

    filepath.IsLocal
    
        |
        v
    
    path-string property
    
    
    os.Root
    
        |
        v
    
    filesystem access property

    6. The Symlink Problem

    Suppose:

    /srv/uploads
    
        avatar.png
            |
            v
        /etc/passwd

    Now:

    f, err := os.Open(
        "/srv/uploads/avatar.png",
    )

    The operating system follows the symbolic link.

    The application may accidentally access:

    /etc/passwd

    instead of:

    /srv/uploads/avatar.png

    7. Why Lstat Does Not Fully Solve It

    A common attempt:

    info, err := os.Lstat(path)
    
    if info.Mode()&os.ModeSymlink != 0 {
        return errors.New("symlink not allowed")
    }
    
    f, err := os.Open(path)

    It seems safer.

    But there is a race.

    The sequence:

    Lstat()
    
        |
        v
    
    check result
    
        |
        v
    
    Open()

    Another process can change the filesystem between these operations.

    Example:

    time 1:
    
    avatar.png
        |
        v
    safe file
    
    
    time 2:
    
    attacker replaces it
    
    
    time 3:
    
    Open()
        |
        v
    unexpected target

    This is another TOCTOU problem:

    Time Of Check
            +
    Time Of Use

    8. Avoid Manual Security Pipelines

    Historically, developers often built something like:

    filepath.Clean
    
    +
    
    filepath.EvalSymlinks
    
    +
    
    Lstat
    
    +
    
    prefix check
    
    +
    
    Open

    The intention is good.

    The problem:

    each step observes the filesystem at a different moment.

    Between steps:

    filesystem can change

    Security decisions based on stale observations are fragile.


    9. Go 1.26: Use Root-Confined Access

    Modern Go provides a better abstraction.

    Instead of:

    path := filepath.Join(root, userInput)
    
    os.Open(path)

    use:

    f, err := os.OpenInRoot(
        root,
        userInput,
    )

    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:

    root, err := os.OpenRoot("/srv/uploads")
    if err != nil {
        return err
    }
    
    defer root.Close()

    Then:

    f, err := root.Open(name)
    if err != nil {
        return err
    }
    
    defer f.Close()

    The root becomes a managed resource.

    Conceptually:

    trusted root
    
          |
          v
    
    root-relative filesystem operations
    
          |
          v
    
    application files

    This matches the actual security requirement.


    11. Example: Secure Upload Storage

    Imagine:

    /srv/uploads/
    
        user1/
        user2/
        user3/

    A request contains:

    filename = "avatar.png"

    Unsafe:

    func openUpload(filename string) (*os.File, error) {
    
        path := filepath.Join(
            "/srv/uploads",
            filename,
        )
    
        return os.Open(path)
    }

    The program constructs a path.

    It does not enforce containment.


    Better:

    func openUpload(filename string) (*os.File, error) {
    
        return os.OpenInRoot(
            "/srv/uploads",
            filename,
        )
    }

    The security boundary is now explicit.


    12. os.Root Is Not a Sandbox

    A common misunderstanding:

    Does os.Root make this directory a container?

    No.

    os.Root provides:

    filesystem path confinement

    It does not provide:

    complete process isolation

    It does not replace:

    • containers;
    • namespaces;
    • virtual machines;
    • operating-system security mechanisms.

    A better model:

    os.Root
    
        =
    safe filesystem traversal
    
    not
    
        =
    host isolation

    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:

    fs.ValidPath(name)

    It validates paths used by filesystem interfaces.

    For example:

    embed.FS
    testing filesystems
    virtual filesystems

    It does not mean:

    This host filesystem operation cannot escape.

    Different layers:

    fs.ValidPath
    
        |
        v
    
    io/fs path rules
    
    
    
    filepath.IsLocal
    
        |
        v
    
    host path string rules
    
    
    
    os.Root
    
        |
        v
    
    filesystem traversal boundary

    Do not mix them.


    15. Archive Extraction Example

    Archive extraction is a classic security problem.

    Unsafe:

    for _, file := range archive.Files {
    
        target :=
            filepath.Join(
                destination,
                file.Name,
            )
    
        extract(file, target)
    }

    An archive entry:

    ../../secret.txt

    can escape.

    Even after cleaning:

    filepath.Clean(file.Name)

    symbolic links can create additional problems.

    A safer design:

    trusted extraction root
    
            |
            v
    
    root-relative creation
    
            |
            v
    
    files appear only inside root

    16. Deleting User-Controlled Paths

    Deletion has even higher risk.

    Dangerous:

    os.RemoveAll(
        filepath.Join(
            root,
            userInput,
        ),
    )

    Because a mistake can become:

    wrong path
    
        |
    
    recursive deletion
    
        |
    
    many filesystem mutations

    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?

    Ask:

    • Can symbolic links exist?
    • Can another process modify the directory?

    APIs

    Prefer:

    os.OpenInRoot
    os.Root

    when the requirement is:

    stay inside this directory.

    Avoid assuming:

    Clean + Join = Security

    Conclusion

    Secure file access in Go is not about finding the perfect string manipulation function.

    The filesystem is a dynamic namespace.

    Between:

    check

    and:

    use

    the world can change.

    The most reliable design is to express security requirements directly:

    trusted root
    
          |
    
    root-confined operation
    
          |
    
    filesystem resource

    Modern Go provides the tools to make this boundary explicit.

    Use them.