main
Raw Download raw file
  1//go:build linux
  2
  3// Copyright (C) 2024 SUSE LLC. All rights reserved.
  4// Use of this source code is governed by a BSD-style
  5// license that can be found in the LICENSE file.
  6
  7package securejoin
  8
  9import (
 10	"errors"
 11	"fmt"
 12	"os"
 13	"path/filepath"
 14	"strings"
 15
 16	"golang.org/x/sys/unix"
 17)
 18
 19var hasOpenat2 = sync_OnceValue(func() bool {
 20	fd, err := unix.Openat2(unix.AT_FDCWD, ".", &unix.OpenHow{
 21		Flags:   unix.O_PATH | unix.O_CLOEXEC,
 22		Resolve: unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_IN_ROOT,
 23	})
 24	if err != nil {
 25		return false
 26	}
 27	_ = unix.Close(fd)
 28	return true
 29})
 30
 31func scopedLookupShouldRetry(how *unix.OpenHow, err error) bool {
 32	// RESOLVE_IN_ROOT (and RESOLVE_BENEATH) can return -EAGAIN if we resolve
 33	// ".." while a mount or rename occurs anywhere on the system. This could
 34	// happen spuriously, or as the result of an attacker trying to mess with
 35	// us during lookup.
 36	//
 37	// In addition, scoped lookups have a "safety check" at the end of
 38	// complete_walk which will return -EXDEV if the final path is not in the
 39	// root.
 40	return how.Resolve&(unix.RESOLVE_IN_ROOT|unix.RESOLVE_BENEATH) != 0 &&
 41		(errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EXDEV))
 42}
 43
 44const scopedLookupMaxRetries = 10
 45
 46func openat2File(dir *os.File, path string, how *unix.OpenHow) (*os.File, error) {
 47	fullPath := dir.Name() + "/" + path
 48	// Make sure we always set O_CLOEXEC.
 49	how.Flags |= unix.O_CLOEXEC
 50	var tries int
 51	for tries < scopedLookupMaxRetries {
 52		fd, err := unix.Openat2(int(dir.Fd()), path, how)
 53		if err != nil {
 54			if scopedLookupShouldRetry(how, err) {
 55				// We retry a couple of times to avoid the spurious errors, and
 56				// if we are being attacked then returning -EAGAIN is the best
 57				// we can do.
 58				tries++
 59				continue
 60			}
 61			return nil, &os.PathError{Op: "openat2", Path: fullPath, Err: err}
 62		}
 63		// If we are using RESOLVE_IN_ROOT, the name we generated may be wrong.
 64		// NOTE: The procRoot code MUST NOT use RESOLVE_IN_ROOT, otherwise
 65		//       you'll get infinite recursion here.
 66		if how.Resolve&unix.RESOLVE_IN_ROOT == unix.RESOLVE_IN_ROOT {
 67			if actualPath, err := rawProcSelfFdReadlink(fd); err == nil {
 68				fullPath = actualPath
 69			}
 70		}
 71		return os.NewFile(uintptr(fd), fullPath), nil
 72	}
 73	return nil, &os.PathError{Op: "openat2", Path: fullPath, Err: errPossibleAttack}
 74}
 75
 76func lookupOpenat2(root *os.File, unsafePath string, partial bool) (*os.File, string, error) {
 77	if !partial {
 78		file, err := openat2File(root, unsafePath, &unix.OpenHow{
 79			Flags:   unix.O_PATH | unix.O_CLOEXEC,
 80			Resolve: unix.RESOLVE_IN_ROOT | unix.RESOLVE_NO_MAGICLINKS,
 81		})
 82		return file, "", err
 83	}
 84	return partialLookupOpenat2(root, unsafePath)
 85}
 86
 87// partialLookupOpenat2 is an alternative implementation of
 88// partialLookupInRoot, using openat2(RESOLVE_IN_ROOT) to more safely get a
 89// handle to the deepest existing child of the requested path within the root.
 90func partialLookupOpenat2(root *os.File, unsafePath string) (*os.File, string, error) {
 91	// TODO: Implement this as a git-bisect-like binary search.
 92
 93	unsafePath = filepath.ToSlash(unsafePath) // noop
 94	endIdx := len(unsafePath)
 95	var lastError error
 96	for endIdx > 0 {
 97		subpath := unsafePath[:endIdx]
 98
 99		handle, err := openat2File(root, subpath, &unix.OpenHow{
100			Flags:   unix.O_PATH | unix.O_CLOEXEC,
101			Resolve: unix.RESOLVE_IN_ROOT | unix.RESOLVE_NO_MAGICLINKS,
102		})
103		if err == nil {
104			// Jump over the slash if we have a non-"" remainingPath.
105			if endIdx < len(unsafePath) {
106				endIdx += 1
107			}
108			// We found a subpath!
109			return handle, unsafePath[endIdx:], lastError
110		}
111		if errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENOTDIR) {
112			// That path doesn't exist, let's try the next directory up.
113			endIdx = strings.LastIndexByte(subpath, '/')
114			lastError = err
115			continue
116		}
117		return nil, "", fmt.Errorf("open subpath: %w", err)
118	}
119	// If we couldn't open anything, the whole subpath is missing. Return a
120	// copy of the root fd so that the caller doesn't close this one by
121	// accident.
122	rootClone, err := dupFile(root)
123	if err != nil {
124		return nil, "", err
125	}
126	return rootClone, unsafePath, lastError
127}