Skip to content

Commit 77990e7

Browse files
committed
libutil/file-descriptor: Add safer utilities for opening files relative to dirFd
Implements a safe no symlink following primitive operation for opening file descriptors. This is unix-only for the time being, since windows doesn't really suffer from symlink races, since they are admin-only. Tested with enosys --syscall openat2 as well.
1 parent 5d06638 commit 77990e7

File tree

3 files changed

+217
-0
lines changed

3 files changed

+217
-0
lines changed

src/libutil-tests/file-system.cc

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
#include "nix/util/fs-sink.hh"
12
#include "nix/util/util.hh"
23
#include "nix/util/types.hh"
34
#include "nix/util/file-system.hh"
@@ -318,4 +319,53 @@ TEST(DirectoryIterator, nonexistent)
318319
ASSERT_THROW(DirectoryIterator("/schnitzel/darmstadt/pommes"), SysError);
319320
}
320321

322+
/* ----------------------------------------------------------------------------
323+
* openFileEnsureBeneathNoSymlinks
324+
* --------------------------------------------------------------------------*/
325+
326+
#ifndef _WIN32
327+
328+
TEST(openFileEnsureBeneathNoSymlinks, works)
329+
{
330+
std::filesystem::path tmpDir = nix::createTempDir();
331+
nix::AutoDelete delTmpDir(tmpDir, /*recursive=*/true);
332+
333+
{
334+
RestoreSink sink(/*startFsync=*/false);
335+
sink.dstPath = tmpDir;
336+
sink.dirFd = openDirectory(tmpDir);
337+
sink.createDirectory(CanonPath("a"));
338+
sink.createDirectory(CanonPath("c"));
339+
sink.createDirectory(CanonPath("c/d"));
340+
sink.createRegularFile(CanonPath("c/d/regular"), [](CreateRegularFileSink & crf) { crf("some contents"); });
341+
sink.createSymlink(CanonPath("a/absolute_symlink"), tmpDir.string());
342+
sink.createSymlink(CanonPath("a/relative_symlink"), "../.");
343+
sink.createSymlink(CanonPath("a/broken_symlink"), "./nonexistent");
344+
}
345+
346+
AutoCloseFD dirFd = openDirectory(tmpDir);
347+
348+
using namespace nix::unix;
349+
350+
auto open = [&](std::string_view path, int flags, mode_t mode = 0) {
351+
return openFileEnsureBeneathNoSymlinks(dirFd.get(), CanonPath(path), flags, mode);
352+
};
353+
354+
EXPECT_THROW(open("a/absolute_symlink", O_RDONLY), SymlinkNotAllowed);
355+
EXPECT_THROW(open("a/relative_symlink", O_RDONLY), SymlinkNotAllowed);
356+
EXPECT_THROW(open("a/absolute_symlink/a", O_RDONLY), SymlinkNotAllowed);
357+
EXPECT_THROW(open("a/absolute_symlink/c/d", O_RDONLY), SymlinkNotAllowed);
358+
EXPECT_THROW(open("a/relative_symlink/c", O_RDONLY), SymlinkNotAllowed);
359+
EXPECT_EQ(open("a/broken_symlink", O_CREAT | O_WRONLY | O_EXCL, 0666), INVALID_DESCRIPTOR);
360+
/* Sanity check, no symlink shenanigans and behaves the same as regular openat with O_EXCL | O_CREAT. */
361+
EXPECT_EQ(errno, EEXIST);
362+
EXPECT_THROW(open("a/absolute_symlink/broken_symlink", O_CREAT | O_WRONLY | O_EXCL, 0666), SymlinkNotAllowed);
363+
EXPECT_EQ(open("c/d/regular/a", O_RDONLY), INVALID_DESCRIPTOR);
364+
EXPECT_EQ(open("c/d/regular", O_RDONLY | O_DIRECTORY), INVALID_DESCRIPTOR);
365+
EXPECT_TRUE(AutoCloseFD{open("c/d/regular", O_RDONLY)});
366+
EXPECT_TRUE(AutoCloseFD{open("a/regular", O_CREAT | O_WRONLY | O_EXCL, 0666)});
367+
}
368+
369+
#endif
370+
321371
} // namespace nix

src/libutil/include/nix/util/file-descriptor.hh

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#pragma once
22
///@file
33

4+
#include "nix/util/canon-path.hh"
45
#include "nix/util/types.hh"
56
#include "nix/util/error.hh"
67

@@ -203,6 +204,26 @@ void closeOnExec(Descriptor fd);
203204
} // namespace unix
204205
#endif
205206

207+
#ifdef __linux__
208+
namespace linux {
209+
210+
/**
211+
* Wrapper around Linux's openat2 syscall introduced in Linux 5.6.
212+
*
213+
* @see https://man7.org/linux/man-pages/man2/openat2.2.html
214+
* @see https://man7.org/linux/man-pages/man2/open_how.2type.html
215+
v*
216+
* @param flags O_* flags
217+
* @param mode Mode for O_{CREAT,TMPFILE}
218+
* @param resolve RESOLVE_* flags
219+
*
220+
* @return nullopt if openat2 is not supported by the kernel.
221+
*/
222+
std::optional<Descriptor> openat2(Descriptor dirFd, const char * path, uint64_t flags, uint64_t mode, uint64_t resolve);
223+
224+
} // namespace linux
225+
#endif
226+
206227
#if defined(_WIN32) && _WIN32_WINNT >= 0x0600
207228
namespace windows {
208229

@@ -212,6 +233,43 @@ std::wstring handleToFileName(Descriptor handle);
212233
} // namespace windows
213234
#endif
214235

236+
#ifndef _WIN32
237+
namespace unix {
238+
239+
struct SymlinkNotAllowed : public Error
240+
{
241+
CanonPath path;
242+
243+
SymlinkNotAllowed(CanonPath path)
244+
/* Can't provide better error message, since the parent directory is only known to the caller. */
245+
: Error("relative path '%s' points to a symlink, which is not allowed", path.rel())
246+
, path(std::move(path))
247+
{
248+
}
249+
};
250+
251+
/**
252+
* Safe(r) function to open \param path file relative to \param dirFd, while
253+
* disallowing escaping from a directory and resolving any symlinks in the
254+
* process.
255+
*
256+
* @note When not on Linux or when openat2 is not available this is implemented
257+
* via openat single path component traversal. Uses RESOLVE_BENEATH with openat2
258+
* or O_RESOLVE_BENEATH.
259+
*
260+
* @note Since this is Unix-only path is specified as CanonPath, which models
261+
* Unix-style paths and ensures that there are no .. or . components.
262+
*
263+
* @param flags O_* flags
264+
* @param mode Mode for O_{CREAT,TMPFILE}
265+
*
266+
* @throws SymlinkNotAllowed if any path components
267+
*/
268+
Descriptor openFileEnsureBeneathNoSymlinks(Descriptor dirFd, const CanonPath & path, int flags, mode_t mode = 0);
269+
270+
} // namespace unix
271+
#endif
272+
215273
MakeError(EndOfFile, Error);
216274

217275
} // namespace nix

src/libutil/unix/file-descriptor.cc

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
#include "nix/util/canon-path.hh"
12
#include "nix/util/file-system.hh"
23
#include "nix/util/signals.hh"
34
#include "nix/util/finally.hh"
@@ -7,6 +8,14 @@
78
#include <unistd.h>
89
#include <poll.h>
910

11+
#if defined(__linux__) && defined(__NR_openat2)
12+
# define HAVE_OPENAT2 1
13+
# include <sys/syscall.h>
14+
# include <linux/openat2.h>
15+
#else
16+
# define HAVE_OPENAT2 0
17+
#endif
18+
1019
#include "util-config-private.hh"
1120
#include "util-unix-config-private.hh"
1221

@@ -223,4 +232,104 @@ void unix::closeOnExec(int fd)
223232
throw SysError("setting close-on-exec flag");
224233
}
225234

235+
#ifdef __linux__
236+
237+
namespace linux {
238+
239+
std::optional<Descriptor> openat2(Descriptor dirFd, const char * path, uint64_t flags, uint64_t mode, uint64_t resolve)
240+
{
241+
# if HAVE_OPENAT2
242+
/* Cache the result of whether openat2 is not supported. */
243+
static std::atomic_flag unsupported{};
244+
245+
if (!unsupported.test()) {
246+
/* No glibc wrapper yet, but there's a patch:
247+
* https://patchwork.sourceware.org/project/glibc/patch/20251029200519.3203914-1-adhemerval.zanella@linaro.org/
248+
*/
249+
auto how = ::open_how{.flags = flags, .mode = mode, .resolve = resolve};
250+
auto res = ::syscall(__NR_openat2, dirFd, path, &how, sizeof(how));
251+
/* Cache that the syscall is not supported. */
252+
if (res < 0 && errno == ENOSYS) {
253+
unsupported.test_and_set();
254+
return std::nullopt;
255+
}
256+
257+
return res;
258+
}
259+
# endif
260+
return std::nullopt;
261+
}
262+
263+
} // namespace linux
264+
265+
#endif
266+
267+
static Descriptor
268+
openFileEnsureBeneathNoSymlinksIterative(Descriptor dirFd, const CanonPath & path, int flags, mode_t mode)
269+
{
270+
AutoCloseFD parentFd;
271+
auto nrComponents = std::ranges::distance(path);
272+
auto components = std::views::take(path, nrComponents - 1); /* Everything but last component */
273+
auto getParentFd = [&]() { return parentFd ? parentFd.get() : dirFd; };
274+
275+
/* This rather convoluted loop is necessary to avoid TOCTOU when validating that
276+
no inner path component is a symlink. */
277+
for (auto it = components.begin(); it != components.end(); ++it) {
278+
auto component = std::string(*it); /* Copy into a string to make NUL terminated. */
279+
assert(component != ".." && !component.starts_with('/')); /* In case invariant is broken somehow.. */
280+
281+
AutoCloseFD parentFd2 = ::openat(
282+
getParentFd(), /* First iteration uses dirFd. */
283+
component.c_str(),
284+
O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC
285+
#ifdef __linux__
286+
| O_PATH /* Linux-specific optimization. Files are open only for path resolution purposes. */
287+
#endif
288+
#ifdef __FreeBSD__
289+
| O_RESOLVE_BENEATH /* Further guard against any possible SNAFUs. */
290+
#endif
291+
);
292+
293+
if (!parentFd2) {
294+
/* Construct the CanonPath for error message. */
295+
auto path2 = std::ranges::fold_left(components.begin(), ++it, CanonPath::root, [](auto lhs, auto rhs) {
296+
lhs.push(rhs);
297+
return lhs;
298+
});
299+
300+
if (errno == ENOTDIR) /* Path component might be a symlink. */ {
301+
struct ::stat st;
302+
if (::fstatat(getParentFd(), component.c_str(), &st, AT_SYMLINK_NOFOLLOW) == 0 && S_ISLNK(st.st_mode))
303+
throw unix::SymlinkNotAllowed(path2);
304+
errno = ENOTDIR; /* Restore the errno. */
305+
} else if (errno == ELOOP) {
306+
throw unix::SymlinkNotAllowed(path2);
307+
}
308+
309+
return INVALID_DESCRIPTOR;
310+
}
311+
312+
parentFd = std::move(parentFd2);
313+
}
314+
315+
auto res = ::openat(getParentFd(), std::string(path.baseName().value()).c_str(), flags | O_NOFOLLOW, mode);
316+
if (res < 0 && errno == ELOOP)
317+
throw unix::SymlinkNotAllowed(path);
318+
return res;
319+
}
320+
321+
Descriptor unix::openFileEnsureBeneathNoSymlinks(Descriptor dirFd, const CanonPath & path, int flags, mode_t mode)
322+
{
323+
#ifdef __linux__
324+
auto maybeFd = linux::openat2(
325+
dirFd, path.rel_c_str(), flags, static_cast<uint64_t>(mode), RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS);
326+
if (maybeFd) {
327+
if (*maybeFd < 0 && errno == ELOOP)
328+
throw unix::SymlinkNotAllowed(path);
329+
return *maybeFd;
330+
}
331+
#endif
332+
return openFileEnsureBeneathNoSymlinksIterative(dirFd, path, flags, mode);
333+
}
334+
226335
} // namespace nix

0 commit comments

Comments
 (0)