main
1// Copyright (C) 2014-2015 Docker Inc & Go Authors. All rights reserved.
2// Copyright (C) 2017-2025 SUSE LLC. All rights reserved.
3// Use of this source code is governed by a BSD-style
4// license that can be found in the LICENSE file.
5
6package securejoin
7
8import (
9 "errors"
10 "os"
11 "path/filepath"
12 "strings"
13 "syscall"
14)
15
16const maxSymlinkLimit = 255
17
18// IsNotExist tells you if err is an error that implies that either the path
19// accessed does not exist (or path components don't exist). This is
20// effectively a more broad version of [os.IsNotExist].
21func IsNotExist(err error) bool {
22 // Check that it's not actually an ENOTDIR, which in some cases is a more
23 // convoluted case of ENOENT (usually involving weird paths).
24 return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) || errors.Is(err, syscall.ENOENT)
25}
26
27// errUnsafeRoot is returned if the user provides SecureJoinVFS with a path
28// that contains ".." components.
29var errUnsafeRoot = errors.New("root path provided to SecureJoin contains '..' components")
30
31// stripVolume just gets rid of the Windows volume included in a path. Based on
32// some godbolt tests, the Go compiler is smart enough to make this a no-op on
33// Linux.
34func stripVolume(path string) string {
35 return path[len(filepath.VolumeName(path)):]
36}
37
38// hasDotDot checks if the path contains ".." components in a platform-agnostic
39// way.
40func hasDotDot(path string) bool {
41 // If we are on Windows, strip any volume letters. It turns out that
42 // C:..\foo may (or may not) be a valid pathname and we need to handle that
43 // leading "..".
44 path = stripVolume(path)
45 // Look for "/../" in the path, but we need to handle leading and trailing
46 // ".."s by adding separators. Doing this with filepath.Separator is ugly
47 // so just convert to Unix-style "/" first.
48 path = filepath.ToSlash(path)
49 return strings.Contains("/"+path+"/", "/../")
50}
51
52// SecureJoinVFS joins the two given path components (similar to [filepath.Join]) except
53// that the returned path is guaranteed to be scoped inside the provided root
54// path (when evaluated). Any symbolic links in the path are evaluated with the
55// given root treated as the root of the filesystem, similar to a chroot. The
56// filesystem state is evaluated through the given [VFS] interface (if nil, the
57// standard [os].* family of functions are used).
58//
59// Note that the guarantees provided by this function only apply if the path
60// components in the returned string are not modified (in other words are not
61// replaced with symlinks on the filesystem) after this function has returned.
62// Such a symlink race is necessarily out-of-scope of SecureJoinVFS.
63//
64// NOTE: Due to the above limitation, Linux users are strongly encouraged to
65// use [OpenInRoot] instead, which does safely protect against these kinds of
66// attacks. There is no way to solve this problem with SecureJoinVFS because
67// the API is fundamentally wrong (you cannot return a "safe" path string and
68// guarantee it won't be modified afterwards).
69//
70// Volume names in unsafePath are always discarded, regardless if they are
71// provided via direct input or when evaluating symlinks. Therefore:
72//
73// "C:\Temp" + "D:\path\to\file.txt" results in "C:\Temp\path\to\file.txt"
74//
75// If the provided root is not [filepath.Clean] then an error will be returned,
76// as such root paths are bordering on somewhat unsafe and using such paths is
77// not best practice. We also strongly suggest that any root path is first
78// fully resolved using [filepath.EvalSymlinks] or otherwise constructed to
79// avoid containing symlink components. Of course, the root also *must not* be
80// attacker-controlled.
81func SecureJoinVFS(root, unsafePath string, vfs VFS) (string, error) {
82 // The root path must not contain ".." components, otherwise when we join
83 // the subpath we will end up with a weird path. We could work around this
84 // in other ways but users shouldn't be giving us non-lexical root paths in
85 // the first place.
86 if hasDotDot(root) {
87 return "", errUnsafeRoot
88 }
89
90 // Use the os.* VFS implementation if none was specified.
91 if vfs == nil {
92 vfs = osVFS{}
93 }
94
95 unsafePath = filepath.FromSlash(unsafePath)
96 var (
97 currentPath string
98 remainingPath = unsafePath
99 linksWalked int
100 )
101 for remainingPath != "" {
102 // On Windows, if we managed to end up at a path referencing a volume,
103 // drop the volume to make sure we don't end up with broken paths or
104 // escaping the root volume.
105 remainingPath = stripVolume(remainingPath)
106
107 // Get the next path component.
108 var part string
109 if i := strings.IndexRune(remainingPath, filepath.Separator); i == -1 {
110 part, remainingPath = remainingPath, ""
111 } else {
112 part, remainingPath = remainingPath[:i], remainingPath[i+1:]
113 }
114
115 // Apply the component lexically to the path we are building.
116 // currentPath does not contain any symlinks, and we are lexically
117 // dealing with a single component, so it's okay to do a filepath.Clean
118 // here.
119 nextPath := filepath.Join(string(filepath.Separator), currentPath, part)
120 if nextPath == string(filepath.Separator) {
121 currentPath = ""
122 continue
123 }
124 fullPath := root + string(filepath.Separator) + nextPath
125
126 // Figure out whether the path is a symlink.
127 fi, err := vfs.Lstat(fullPath)
128 if err != nil && !IsNotExist(err) {
129 return "", err
130 }
131 // Treat non-existent path components the same as non-symlinks (we
132 // can't do any better here).
133 if IsNotExist(err) || fi.Mode()&os.ModeSymlink == 0 {
134 currentPath = nextPath
135 continue
136 }
137
138 // It's a symlink, so get its contents and expand it by prepending it
139 // to the yet-unparsed path.
140 linksWalked++
141 if linksWalked > maxSymlinkLimit {
142 return "", &os.PathError{Op: "SecureJoin", Path: root + string(filepath.Separator) + unsafePath, Err: syscall.ELOOP}
143 }
144
145 dest, err := vfs.Readlink(fullPath)
146 if err != nil {
147 return "", err
148 }
149 remainingPath = dest + string(filepath.Separator) + remainingPath
150 // Absolute symlinks reset any work we've already done.
151 if filepath.IsAbs(dest) {
152 currentPath = ""
153 }
154 }
155
156 // There should be no lexical components like ".." left in the path here,
157 // but for safety clean up the path before joining it to the root.
158 finalPath := filepath.Join(string(filepath.Separator), currentPath)
159 return filepath.Join(root, finalPath), nil
160}
161
162// SecureJoin is a wrapper around [SecureJoinVFS] that just uses the [os].* library
163// of functions as the [VFS]. If in doubt, use this function over [SecureJoinVFS].
164func SecureJoin(root, unsafePath string) (string, error) {
165 return SecureJoinVFS(root, unsafePath, nil)
166}