#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)
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;
* -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
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;
test_abspath_vs_normpath();
test_link_alloc();
test_link_alloc2();
+ test_openat_safe();
test_cleanup();
alarm(0);
test_end();