diff --git a/src/libutil-tests/file-system.cc b/src/libutil-tests/file-system.cc index dfdd26088..3a54ac55b 100644 --- a/src/libutil-tests/file-system.cc +++ b/src/libutil-tests/file-system.cc @@ -1,3 +1,4 @@ +#include "nix/util/fs-sink.hh" #include "nix/util/util.hh" #include "nix/util/types.hh" #include "nix/util/file-system.hh" @@ -318,4 +319,62 @@ TEST(DirectoryIterator, nonexistent) ASSERT_THROW(DirectoryIterator("/schnitzel/darmstadt/pommes"), SysError); } +/* ---------------------------------------------------------------------------- + * openFileEnsureBeneathNoSymlinks + * --------------------------------------------------------------------------*/ + +#ifndef _WIN32 + +TEST(openFileEnsureBeneathNoSymlinks, works) +{ + std::filesystem::path tmpDir = nix::createTempDir(); + nix::AutoDelete delTmpDir(tmpDir, /*recursive=*/true); + using namespace nix::unix; + + { + RestoreSink sink(/*startFsync=*/false); + sink.dstPath = tmpDir; + sink.dirFd = openDirectory(tmpDir); + sink.createDirectory(CanonPath("a")); + sink.createDirectory(CanonPath("c")); + sink.createDirectory(CanonPath("c/d")); + sink.createRegularFile(CanonPath("c/d/regular"), [](CreateRegularFileSink & crf) { crf("some contents"); }); + sink.createSymlink(CanonPath("a/absolute_symlink"), tmpDir.string()); + sink.createSymlink(CanonPath("a/relative_symlink"), "../."); + sink.createSymlink(CanonPath("a/broken_symlink"), "./nonexistent"); + sink.createDirectory(CanonPath("a/b"), [](FileSystemObjectSink & dirSink, const CanonPath & relPath) { + dirSink.createDirectory(CanonPath("d")); + dirSink.createSymlink(CanonPath("c"), "./d"); + }); + sink.createDirectory(CanonPath("a/b/c/e")); // FIXME: This still follows symlinks + ASSERT_THROW( + sink.createDirectory( + CanonPath("a/b/c/f"), [](FileSystemObjectSink & dirSink, const CanonPath & relPath) {}), + SymlinkNotAllowed); + } + + AutoCloseFD dirFd = openDirectory(tmpDir); + + auto open = [&](std::string_view path, int flags, mode_t mode = 0) { + return openFileEnsureBeneathNoSymlinks(dirFd.get(), CanonPath(path), flags, mode); + }; + + EXPECT_THROW(open("a/absolute_symlink", O_RDONLY), SymlinkNotAllowed); + EXPECT_THROW(open("a/relative_symlink", O_RDONLY), SymlinkNotAllowed); + EXPECT_THROW(open("a/absolute_symlink/a", O_RDONLY), SymlinkNotAllowed); + EXPECT_THROW(open("a/absolute_symlink/c/d", O_RDONLY), SymlinkNotAllowed); + EXPECT_THROW(open("a/relative_symlink/c", O_RDONLY), SymlinkNotAllowed); + EXPECT_THROW(open("a/b/c/d", O_RDONLY), SymlinkNotAllowed); + EXPECT_EQ(open("a/broken_symlink", O_CREAT | O_WRONLY | O_EXCL, 0666), INVALID_DESCRIPTOR); + /* Sanity check, no symlink shenanigans and behaves the same as regular openat with O_EXCL | O_CREAT. */ + EXPECT_EQ(errno, EEXIST); + EXPECT_THROW(open("a/absolute_symlink/broken_symlink", O_CREAT | O_WRONLY | O_EXCL, 0666), SymlinkNotAllowed); + EXPECT_EQ(open("c/d/regular/a", O_RDONLY), INVALID_DESCRIPTOR); + EXPECT_EQ(open("c/d/regular", O_RDONLY | O_DIRECTORY), INVALID_DESCRIPTOR); + EXPECT_TRUE(AutoCloseFD{open("c/d/regular", O_RDONLY)}); + EXPECT_TRUE(AutoCloseFD{open("a/regular", O_CREAT | O_WRONLY | O_EXCL, 0666)}); +} + +#endif + } // namespace nix diff --git a/src/libutil/fs-sink.cc b/src/libutil/fs-sink.cc index a78fe1af4..87bdaa339 100644 --- a/src/libutil/fs-sink.cc +++ b/src/libutil/fs-sink.cc @@ -84,7 +84,8 @@ void RestoreSink::createDirectory(const CanonPath & path, DirectoryCreatedCallba RestoreSink dirSink{startFsync}; dirSink.dstPath = append(dstPath, path); - dirSink.dirFd = ::openat(dirFd.get(), path.rel_c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + dirSink.dirFd = + unix::openFileEnsureBeneathNoSymlinks(dirFd.get(), path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); if (!dirSink.dirFd) throw SysError("opening directory '%s'", dirSink.dstPath.string()); diff --git a/src/libutil/include/nix/util/file-descriptor.hh b/src/libutil/include/nix/util/file-descriptor.hh index 3dd2dd8e6..4a6fdc8a2 100644 --- a/src/libutil/include/nix/util/file-descriptor.hh +++ b/src/libutil/include/nix/util/file-descriptor.hh @@ -1,6 +1,7 @@ #pragma once ///@file +#include "nix/util/canon-path.hh" #include "nix/util/types.hh" #include "nix/util/error.hh" @@ -203,6 +204,26 @@ void closeOnExec(Descriptor fd); } // namespace unix #endif +#ifdef __linux__ +namespace linux { + +/** + * Wrapper around Linux's openat2 syscall introduced in Linux 5.6. + * + * @see https://man7.org/linux/man-pages/man2/openat2.2.html + * @see https://man7.org/linux/man-pages/man2/open_how.2type.html +v* + * @param flags O_* flags + * @param mode Mode for O_{CREAT,TMPFILE} + * @param resolve RESOLVE_* flags + * + * @return nullopt if openat2 is not supported by the kernel. + */ +std::optional openat2(Descriptor dirFd, const char * path, uint64_t flags, uint64_t mode, uint64_t resolve); + +} // namespace linux +#endif + #if defined(_WIN32) && _WIN32_WINNT >= 0x0600 namespace windows { @@ -212,6 +233,43 @@ std::wstring handleToFileName(Descriptor handle); } // namespace windows #endif +#ifndef _WIN32 +namespace unix { + +struct SymlinkNotAllowed : public Error +{ + CanonPath path; + + SymlinkNotAllowed(CanonPath path) + /* Can't provide better error message, since the parent directory is only known to the caller. */ + : Error("relative path '%s' points to a symlink, which is not allowed", path.rel()) + , path(std::move(path)) + { + } +}; + +/** + * Safe(r) function to open \param path file relative to \param dirFd, while + * disallowing escaping from a directory and resolving any symlinks in the + * process. + * + * @note When not on Linux or when openat2 is not available this is implemented + * via openat single path component traversal. Uses RESOLVE_BENEATH with openat2 + * or O_RESOLVE_BENEATH. + * + * @note Since this is Unix-only path is specified as CanonPath, which models + * Unix-style paths and ensures that there are no .. or . components. + * + * @param flags O_* flags + * @param mode Mode for O_{CREAT,TMPFILE} + * + * @throws SymlinkNotAllowed if any path components + */ +Descriptor openFileEnsureBeneathNoSymlinks(Descriptor dirFd, const CanonPath & path, int flags, mode_t mode = 0); + +} // namespace unix +#endif + MakeError(EndOfFile, Error); } // namespace nix diff --git a/src/libutil/unix/file-descriptor.cc b/src/libutil/unix/file-descriptor.cc index 2b612e854..ac699d6f1 100644 --- a/src/libutil/unix/file-descriptor.cc +++ b/src/libutil/unix/file-descriptor.cc @@ -1,3 +1,4 @@ +#include "nix/util/canon-path.hh" #include "nix/util/file-system.hh" #include "nix/util/signals.hh" #include "nix/util/finally.hh" @@ -7,6 +8,14 @@ #include #include +#if defined(__linux__) && defined(__NR_openat2) +# define HAVE_OPENAT2 1 +# include +# include +#else +# define HAVE_OPENAT2 0 +#endif + #include "util-config-private.hh" #include "util-unix-config-private.hh" @@ -223,4 +232,104 @@ void unix::closeOnExec(int fd) throw SysError("setting close-on-exec flag"); } +#ifdef __linux__ + +namespace linux { + +std::optional openat2(Descriptor dirFd, const char * path, uint64_t flags, uint64_t mode, uint64_t resolve) +{ +# if HAVE_OPENAT2 + /* Cache the result of whether openat2 is not supported. */ + static std::atomic_flag unsupported{}; + + if (!unsupported.test()) { + /* No glibc wrapper yet, but there's a patch: + * https://patchwork.sourceware.org/project/glibc/patch/20251029200519.3203914-1-adhemerval.zanella@linaro.org/ + */ + auto how = ::open_how{.flags = flags, .mode = mode, .resolve = resolve}; + auto res = ::syscall(__NR_openat2, dirFd, path, &how, sizeof(how)); + /* Cache that the syscall is not supported. */ + if (res < 0 && errno == ENOSYS) { + unsupported.test_and_set(); + return std::nullopt; + } + + return res; + } +# endif + return std::nullopt; +} + +} // namespace linux + +#endif + +static Descriptor +openFileEnsureBeneathNoSymlinksIterative(Descriptor dirFd, const CanonPath & path, int flags, mode_t mode) +{ + AutoCloseFD parentFd; + auto nrComponents = std::ranges::distance(path); + auto components = std::views::take(path, nrComponents - 1); /* Everything but last component */ + auto getParentFd = [&]() { return parentFd ? parentFd.get() : dirFd; }; + + /* This rather convoluted loop is necessary to avoid TOCTOU when validating that + no inner path component is a symlink. */ + for (auto it = components.begin(); it != components.end(); ++it) { + auto component = std::string(*it); /* Copy into a string to make NUL terminated. */ + assert(component != ".." && !component.starts_with('/')); /* In case invariant is broken somehow.. */ + + AutoCloseFD parentFd2 = ::openat( + getParentFd(), /* First iteration uses dirFd. */ + component.c_str(), + O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC +#ifdef __linux__ + | O_PATH /* Linux-specific optimization. Files are open only for path resolution purposes. */ +#endif +#ifdef __FreeBSD__ + | O_RESOLVE_BENEATH /* Further guard against any possible SNAFUs. */ +#endif + ); + + if (!parentFd2) { + /* Construct the CanonPath for error message. */ + auto path2 = std::ranges::fold_left(components.begin(), ++it, CanonPath::root, [](auto lhs, auto rhs) { + lhs.push(rhs); + return lhs; + }); + + if (errno == ENOTDIR) /* Path component might be a symlink. */ { + struct ::stat st; + if (::fstatat(getParentFd(), component.c_str(), &st, AT_SYMLINK_NOFOLLOW) == 0 && S_ISLNK(st.st_mode)) + throw unix::SymlinkNotAllowed(path2); + errno = ENOTDIR; /* Restore the errno. */ + } else if (errno == ELOOP) { + throw unix::SymlinkNotAllowed(path2); + } + + return INVALID_DESCRIPTOR; + } + + parentFd = std::move(parentFd2); + } + + auto res = ::openat(getParentFd(), std::string(path.baseName().value()).c_str(), flags | O_NOFOLLOW, mode); + if (res < 0 && errno == ELOOP) + throw unix::SymlinkNotAllowed(path); + return res; +} + +Descriptor unix::openFileEnsureBeneathNoSymlinks(Descriptor dirFd, const CanonPath & path, int flags, mode_t mode) +{ +#ifdef __linux__ + auto maybeFd = linux::openat2( + dirFd, path.rel_c_str(), flags, static_cast(mode), RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS); + if (maybeFd) { + if (*maybeFd < 0 && errno == ELOOP) + throw unix::SymlinkNotAllowed(path); + return *maybeFd; + } +#endif + return openFileEnsureBeneathNoSymlinksIterative(dirFd, path, flags, mode); +} + } // namespace nix