From: Timo Sirainen Date: Mon, 4 May 2026 13:08:16 +0000 (+0000) Subject: [PATCH 2/5] lib: path-util - Add t_openat_safe() for jailed openat() resolution X-Git-Tag: archive/raspbian/1%2.4.1+dfsg1-6+rpi1+deb13u7^2~99 X-Git-Url: https://dgit.raspbian.org/?a=commitdiff_plain;h=37e5c021f639fe5b85d4667ee3133cec5ba19244;p=dovecot.git [PATCH 2/5] lib: path-util - Add t_openat_safe() for jailed openat() resolution 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 --- diff --git a/src/lib/path-util.c b/src/lib/path-util.c index 90e1e46..3b8c154 100644 --- a/src/lib/path-util.c +++ b/src/lib/path-util.c @@ -4,12 +4,17 @@ #include "str.h" #include "path-util.h" +#include #include #include #include #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; diff --git a/src/lib/path-util.h b/src/lib/path-util.h index 8492cf3..ec64ba6 100644 --- a/src/lib/path-util.h +++ b/src/lib/path-util.h @@ -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 diff --git a/src/lib/test-path-util.c b/src/lib/test-path-util.c index c5d4122..66c551a 100644 --- a/src/lib/test-path-util.c +++ b/src/lib/test-path-util.c @@ -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();