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