]> dgit.raspbian.org Git - dovecot.git/commitdiff
[PATCH 2/5] lib: path-util - Add t_openat_safe() for jailed openat() resolution
authorTimo Sirainen <timo.sirainen@open-xchange.com>
Mon, 4 May 2026 13:08:16 +0000 (13:08 +0000)
committerNoah Meyerhans <noahm@debian.org>
Wed, 16 Sep 2026 19:06:35 +0000 (15:06 -0400)
Add a helper that opens a path relative to a directory fd while refusing
any resolution that escapes that directory. Each component is opened with
O_NOFOLLOW so symlinks are detected explicitly rather than transparently
followed; symlinks are then resolved manually and only honoured when their
(recursively resolved) target also stays beneath the base fd. Absolute
symlink targets, leading '/', and '..' past the base are rejected with
errno=ELOOP. Symlink chains are bounded by
PATH_UTIL_OPENAT_SAFE_MAX_SYMLINKS hops.

Anchoring resolution at a caller-held base_fd makes the lookup TOCTOU-safe
even when intermediate path components are mutated on disk concurrently:
the kernel resolves all openat() lookups relative to the original
directory inode that base_fd refers to, so an attacker mutating path
components on the filesystem cannot redirect resolution.

This is the portable counterpart to Linux's openat2() with RESOLVE_BENEATH
and is intended for callers that need to safely look up files under a
user-writable directory (e.g. a Sieve script storage directory).

Gbp-Pq: Name 0002-lib-path-util-Add-t_openat_safe-for-jailed-openat-re.patch

src/lib/path-util.c
src/lib/path-util.h
src/lib/test-path-util.c

index 90e1e46e92ff0f47d5e5334d01ebfb8dee94ef5f..3b8c1544f5a8bf15732a9dc56014471651ff9b62 100644 (file)
@@ -4,12 +4,17 @@
 #include "str.h"
 #include "path-util.h"
 
+#include <fcntl.h>
 #include <unistd.h>
 #include <sys/types.h>
 #include <sys/stat.h>
 
 #define PATH_UTIL_MAX_PATH      8*1024
 #define PATH_UTIL_MAX_SYMLINKS  80
+/* Symlink-hop cap for t_openat_safe(). Lower than the realpath cap:
+   t_openat_safe() refuses absolute and escaping targets outright, so a
+   long chain inside the base dir is the only legitimate use. */
+#define PATH_UTIL_OPENAT_SAFE_MAX_SYMLINKS 16
 
 static int t_getcwd_noalloc(char **dir_r, size_t *asize_r,
                            const char **error_r) ATTR_NULL(2)
@@ -358,6 +363,171 @@ int t_readlink(const char *path, const char **dest_r, const char **error_r)
        return 0;
 }
 
+/* Iteratively resolve `path` beneath `cur_fd` using openat() with
+   O_NOFOLLOW per component. Symlinks are resolved manually: their targets
+   are validated (non-absolute, no '..' past the base) and the loop
+   restarts with the target spliced into the remaining path. dir_depth
+   tracks how many directory levels we have descended below the original
+   base; '..' is refused when the depth would go negative.
+ */
+static int
+path_openat_safe_walk(int cur_fd, const char *path, int flags,
+                     const char **error_r)
+{
+       bool cur_owned = FALSE;
+       unsigned int symlink_count = 0;
+       unsigned int dir_depth = 0;
+
+       /* Absolute paths cannot stay beneath the base directory. */
+       if (*path == '/') {
+               errno = ELOOP;
+               *error_r = "Path is absolute (escapes base directory)";
+               return -1;
+       }
+
+       for (;;) {
+               while (*path == '/')
+                       path++;
+
+               if (*path == '\0') {
+                       /* Empty path or trailing slash: open the current
+                          directory itself with the requested flags. */
+                       int fd = openat(cur_fd, ".",
+                                       flags | O_NOFOLLOW | O_CLOEXEC);
+                       if (cur_owned)
+                               i_close_fd(&cur_fd);
+                       if (fd < 0) {
+                               *error_r = t_strdup_printf(
+                                       "openat() failed: %m");
+                       }
+                       return fd;
+               }
+
+               const char *slash = strchr(path, '/');
+               size_t comp_len = (slash != NULL ?
+                                  (size_t)(slash - path) : strlen(path));
+               bool is_last = (slash == NULL);
+
+               if (comp_len == 1 && path[0] == '.') {
+                       path += comp_len;
+                       continue;
+               }
+
+               if (comp_len == 2 && path[0] == '.' && path[1] == '.') {
+                       if (dir_depth == 0) {
+                               if (cur_owned)
+                                       i_close_fd(&cur_fd);
+                               errno = ELOOP;
+                               *error_r = "Path escapes base directory via '..'";
+                               return -1;
+                       }
+                       int parent_fd = openat(cur_fd, "..",
+                                              O_RDONLY | O_DIRECTORY |
+                                              O_NOFOLLOW | O_CLOEXEC);
+                       if (cur_owned)
+                               i_close_fd(&cur_fd);
+                       if (parent_fd < 0) {
+                               *error_r = t_strdup_printf(
+                                       "openat(..) failed: %m");
+                               return -1;
+                       }
+                       cur_fd = parent_fd;
+                       cur_owned = TRUE;
+                       dir_depth--;
+                       path += comp_len;
+                       continue;
+               }
+
+               const char *comp = t_strdup_until(path, path + comp_len);
+               int next_fd;
+               if (is_last) {
+                       next_fd = openat(cur_fd, comp,
+                                        flags | O_NOFOLLOW | O_CLOEXEC);
+               } else {
+                       next_fd = openat(cur_fd, comp,
+                                        O_RDONLY | O_DIRECTORY |
+                                        O_NOFOLLOW | O_CLOEXEC);
+               }
+
+               if (next_fd < 0 && (errno == ELOOP || errno == EMLINK)) {
+                       /* Component is a symlink. Resolve it ourselves and
+                          restart processing with its target spliced into
+                          `path`; that way the target is itself walked
+                          component-by-component and the boundary check
+                          continues to apply. (O_NOFOLLOW on a symlink reports
+                          ELOOP on Linux/macOS but EMLINK on the BSDs, so both
+                          must be treated as "this is a symlink".) */
+                       if (++symlink_count >
+                           PATH_UTIL_OPENAT_SAFE_MAX_SYMLINKS) {
+                               if (cur_owned)
+                                       i_close_fd(&cur_fd);
+                               errno = ELOOP;
+                               *error_r = "Too many symlink dereferences";
+                               return -1;
+                       }
+
+                       char buf[PATH_UTIL_MAX_PATH];
+                       ssize_t llen = readlinkat(cur_fd, comp,
+                                                 buf, sizeof(buf) - 1);
+                       if (llen < 0) {
+                               if (cur_owned)
+                                       i_close_fd(&cur_fd);
+                               *error_r = t_strdup_printf(
+                                       "readlinkat(%s) failed: %m", comp);
+                               return -1;
+                       }
+                       buf[llen] = '\0';
+
+                       if (buf[0] == '/') {
+                               if (cur_owned)
+                                       i_close_fd(&cur_fd);
+                               errno = ELOOP;
+                               *error_r = t_strdup_printf(
+                                       "Symlink '%s' has absolute target "
+                                       "'%s' (escapes base directory)",
+                                       comp, buf);
+                               return -1;
+                       }
+
+                       path = is_last ? t_strdup(buf) :
+                                        t_strconcat(buf, "/",
+                                                    slash + 1, NULL);
+                       continue;
+               }
+
+               if (next_fd < 0) {
+                       if (cur_owned)
+                               i_close_fd(&cur_fd);
+                       *error_r = t_strdup_printf(
+                               "openat(%s) failed: %m", comp);
+                       return -1;
+               }
+
+               if (is_last) {
+                       if (cur_owned)
+                               i_close_fd(&cur_fd);
+                       return next_fd;
+               }
+
+               if (cur_owned)
+                       i_close_fd(&cur_fd);
+               cur_fd = next_fd;
+               cur_owned = TRUE;
+               dir_depth++;
+               path = slash + 1;
+       }
+}
+
+int t_openat_safe(int base_fd, const char *path, int flags,
+                 const char **error_r)
+{
+       i_assert(base_fd >= 0);
+       i_assert(path != NULL);
+       i_assert(error_r != NULL);
+
+       return path_openat_safe_walk(base_fd, path, flags, error_r);
+}
+
 bool t_binary_abspath(const char **binpath, const char **error_r)
 {
        const char *path_env, *const *paths;
index 8492cf370c4ad3770211daa06e989b1d2359fa8f..ec64ba68d302e90ac3657c13163e7a93dcd42253 100644 (file)
@@ -57,6 +57,35 @@ int t_get_working_dir(const char **dir_r, const char **error_r);
  * -1 on failure. error_r is set on failure and cannot be NULL. */
 int t_readlink(const char *path, const char **dest_r, const char **error_r);
 
+/* Open `path` relative to `base_fd`, refusing any resolution that escapes
+   the directory referenced by `base_fd`. Each path component is opened with
+   O_NOFOLLOW so symlinks are detected explicitly rather than transparently
+   followed; symlinks are followed only when their (recursively resolved)
+   target also stays beneath `base_fd`. Absolute symlink targets, leading
+   '/', and '..' past the base are rejected.
+
+   `base_fd` must reference a directory (e.g. obtained via
+   open(dir, O_DIRECTORY|O_RDONLY|O_CLOEXEC)) and remains owned by the
+   caller; this function does not close it.
+
+   `flags` is OR-ed into the openat() call for the final component (e.g.
+   O_RDONLY). O_NOFOLLOW and O_CLOEXEC are added implicitly.
+
+   Anchoring resolution at `base_fd` makes the lookup TOCTOU-safe even when
+   intermediate path components are mutated on disk concurrently: the kernel
+   resolves all component lookups relative to the original directory inode
+   `base_fd` references.
+
+   Returns the new fd (>= 0) on success. On failure returns -1 with errno
+   set and *error_r set to a descriptive message:
+    errno=ELOOP   target escapes base, or symlink chain too deep
+    errno=ENOENT  file not found
+    errno=EACCES  permission denied
+    other errno   as set by openat()/readlinkat()
+ */
+int t_openat_safe(int base_fd, const char *path, int flags,
+                 const char **error_r);
+
 /* Update binpath to be absolute:
  * a) begins with '/' -> no change
  * b) contains '/' -> assume relative to working directory
index c5d4122146eb75127e4be0b28aae51946a793e4b..66c551a34a428c757193f761c7f11b1b03302d7f 100644 (file)
@@ -233,6 +233,129 @@ static void test_link_alloc2(void)
        tmpdir = o_tmpdir;
 }
 
+static void test_openat_safe(void)
+{
+       const char *error;
+       int base_fd, fd;
+
+       base_fd = open(tmpdir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+       if (base_fd < 0)
+               i_fatal("open(%s) failed: %m", tmpdir);
+
+       /* Plain regular file inside base. */
+       const char *plain = t_strconcat(tmpdir, "/plain", NULL);
+       int wfd = creat(plain, 0600);
+       if (wfd < 0)
+               i_fatal("creat(%s) failed: %m", plain);
+       i_close_fd(&wfd);
+
+       fd = t_openat_safe(base_fd, "plain", O_RDONLY, &error);
+       test_assert(fd >= 0);
+       i_close_fd(&fd);
+
+       /* "./plain" and redundant slashes. */
+       fd = t_openat_safe(base_fd, "./plain", O_RDONLY, &error);
+       test_assert(fd >= 0);
+       i_close_fd(&fd);
+       fd = t_openat_safe(base_fd, ".//plain", O_RDONLY, &error);
+       test_assert(fd >= 0);
+       i_close_fd(&fd);
+
+       /* Symlink whose target is inside the base: allowed. */
+       const char *in_link = t_strconcat(tmpdir, "/in-link", NULL);
+       if (symlink("plain", in_link) < 0)
+               i_fatal("symlink(plain, %s) failed: %m", in_link);
+       fd = t_openat_safe(base_fd, "in-link", O_RDONLY, &error);
+       test_assert(fd >= 0);
+       i_close_fd(&fd);
+
+       /* Absolute symlink target: refused. */
+       const char *abs_link = t_strconcat(tmpdir, "/abs-link", NULL);
+       if (symlink("/etc/hostname", abs_link) < 0)
+               i_fatal("symlink failed: %m");
+       errno = 0;
+       fd = t_openat_safe(base_fd, "abs-link", O_RDONLY, &error);
+       test_assert(fd == -1);
+       test_assert(errno == ELOOP);
+       test_assert(error != NULL);
+
+       /* Relative symlink with '..' that escapes the base: refused. */
+       const char *escape_link = t_strconcat(tmpdir, "/escape-link", NULL);
+       if (symlink("../../etc/hostname", escape_link) < 0)
+               i_fatal("symlink failed: %m");
+       errno = 0;
+       fd = t_openat_safe(base_fd, "escape-link", O_RDONLY, &error);
+       test_assert(fd == -1);
+       test_assert(errno == ELOOP);
+       test_assert(error != NULL);
+
+       /* Direct '..' escape attempt: refused. */
+       errno = 0;
+       fd = t_openat_safe(base_fd, "../plain", O_RDONLY, &error);
+       test_assert(fd == -1);
+       test_assert(errno == ELOOP);
+
+       /* Absolute literal path: refused. */
+       errno = 0;
+       fd = t_openat_safe(base_fd, "/etc/hostname", O_RDONLY, &error);
+       test_assert(fd == -1);
+       test_assert(errno == ELOOP);
+
+       /* link2's target is an absolute path (tmpdir/link3): refused as an
+          absolute target before the loop is ever followed. */
+       errno = 0;
+       fd = t_openat_safe(base_fd, "link2", O_RDONLY, &error);
+       test_assert(fd == -1);
+       test_assert(errno == ELOOP);
+
+       /* Relative symlink loop staying inside the base: nothing rejects it
+          except the hop counter, so this exercises
+          PATH_UTIL_OPENAT_SAFE_MAX_SYMLINKS. */
+       const char *loopa = t_strconcat(tmpdir, "/loopa", NULL);
+       const char *loopb = t_strconcat(tmpdir, "/loopb", NULL);
+       if (symlink("loopb", loopa) < 0)
+               i_fatal("symlink(loopb, %s) failed: %m", loopa);
+       if (symlink("loopa", loopb) < 0)
+               i_fatal("symlink(loopa, %s) failed: %m", loopb);
+       errno = 0;
+       fd = t_openat_safe(base_fd, "loopa", O_RDONLY, &error);
+       test_assert(fd == -1);
+       test_assert(errno == ELOOP);
+       test_assert(error != NULL);
+
+       /* '..' that stays within the base: allowed. Create sub/under and
+          reach back to plain via sub/../plain. */
+       const char *subdir = t_strconcat(tmpdir, "/sub", NULL);
+       if (mkdir(subdir, 0700) < 0 && errno != EEXIST)
+               i_fatal("mkdir(%s) failed: %m", subdir);
+       fd = t_openat_safe(base_fd, "sub/../plain", O_RDONLY, &error);
+       test_assert(fd >= 0);
+       i_close_fd(&fd);
+
+       /* Breaking out of the base via subdirectory. */
+       errno = 0;
+       fd = t_openat_safe(base_fd, "sub/../../plain", O_RDONLY, &error);
+       test_assert(fd == -1);
+       test_assert(errno == ELOOP);
+
+       /* Nonexistent file inside base: ENOENT, not ELOOP. */
+       errno = 0;
+       fd = t_openat_safe(base_fd, "no-such-file", O_RDONLY, &error);
+       test_assert(fd == -1);
+       test_assert(errno == ENOENT);
+
+       i_close_fd(&base_fd);
+
+       i_unlink(plain);
+       i_unlink(in_link);
+       i_unlink(abs_link);
+       i_unlink(escape_link);
+       i_unlink(loopa);
+       i_unlink(loopb);
+       if (rmdir(subdir) < 0)
+               i_error("rmdir(%s) failed: %m", subdir);
+}
+
 static void test_cleanup(void)
 {
        const char *error;
@@ -272,6 +395,7 @@ void test_path_util(void)
        test_abspath_vs_normpath();
        test_link_alloc();
        test_link_alloc2();
+       test_openat_safe();
        test_cleanup();
        alarm(0);
        test_end();