main
Raw Download raw file
  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}