feat: add confined symlink-safe directory-tree removal helper
CI / lint (pull_request) Successful in 32s
CI / sanitizers (address) (pull_request) Failing after 42s
CI / sanitizers (undefined) (pull_request) Successful in 43s
CI / fuzz-build (pull_request) Successful in 18s
CI / coverage (pull_request) Successful in 35s
CI / valgrind (pull_request) Failing after 36s
CI / build-and-test (pull_request) Successful in 10m49s

file_remove_tree_secure() opens the final component O_NOFOLLOW below the
authorized root and recursively wipes it with an fd-relative walk (symlinks are
removed by name, never followed); --force uses it to clear a destination
directory that blocks an incoming regular file.
This commit is contained in:
2026-09-06 21:50:25 +02:00
parent 6e835fd9f7
commit 356b5592e0
2 changed files with 78 additions and 0 deletions
+74
View File
@@ -1,4 +1,5 @@
#include <errno.h>
#include <dirent.h>
#include <fcntl.h>
#include <libgen.h>
#include <limits.h>
@@ -410,6 +411,79 @@ bool file_rename_secure(const char* old_path, const char* new_path) {
return ok;
}
/* Recursively delete every entry inside an open directory, never following a
symlink (an O_NOFOLLOW fd walk, so a symlink planted inside the tree can
never redirect removal outside of it). The directory itself is left in
place; returns false on any failure. */
static bool wipe_dir_fd(int dirfd) {
int scanfd = dup(dirfd);
if (scanfd < 0)
return false;
DIR* dir = fdopendir(scanfd);
if (!dir) {
close(scanfd);
return false;
}
bool operation_ok = true;
const struct dirent* entry;
while ((entry = readdir(dir)) != NULL) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
continue;
struct stat st;
if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) {
if (errno != ENOENT)
operation_ok = false;
continue;
}
if (S_ISDIR(st.st_mode)) {
int childfd = openat(dirfd, entry->d_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
bool child_removed = false;
if (childfd >= 0) {
child_removed = wipe_dir_fd(childfd);
close(childfd);
} else if (errno != ENOENT) {
operation_ok = false;
}
if (child_removed && unlinkat(dirfd, entry->d_name, AT_REMOVEDIR) != 0 && errno != ENOENT)
operation_ok = false;
} else {
/* Files and symlinks alike are removed by name, never followed. */
if (unlinkat(dirfd, entry->d_name, 0) != 0 && errno != ENOENT)
operation_ok = false;
}
}
closedir(dir);
return operation_ok;
}
/* Remove the whole directory tree at `path` (confined below the authorized
root, symlink-safe). --force uses this to clear a non-empty destination
directory that blocks an incoming regular file. Returns true when the path
no longer exists as a directory (a missing path or a non-directory at the
final component is a no-op success; the normal write path replaces files). */
bool file_remove_tree_secure(const char* path) {
if (!path)
return false;
char* leaf = NULL;
int parent_fd = file_open_secure_parent(path, &leaf, false);
if (parent_fd < 0)
return false;
int dirfd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
if (dirfd < 0) {
bool absent = errno == ENOENT || errno == ENOTDIR || errno == ELOOP;
close(parent_fd);
free(leaf);
return absent;
}
bool ok = wipe_dir_fd(dirfd);
close(dirfd);
if (ok && unlinkat(parent_fd, leaf, AT_REMOVEDIR) != 0 && errno != ENOENT)
ok = false;
close(parent_fd);
free(leaf);
return ok;
}
/* Open a private staging/scratch directory, creating it (and any missing path
components) on demand. dir_path is expected to already be confined below
the authorized root by the caller; file_open_secure_parent re-checks that
+4
View File
@@ -32,6 +32,10 @@ int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs)
bool file_ensure_directory_secure(const char* path);
bool file_directory_exists_secure(const char* path);
bool file_rename_secure(const char* old_path, const char* new_path);
/* Remove the whole directory tree at `path` (confined, symlink-safe). Used by
--force to clear a non-empty destination directory that blocks an incoming
regular file. See the .c for the exact success semantics. */
bool file_remove_tree_secure(const char* path);
/* Open a private 0700 directory (creating it on demand) that must live below
the authorized root. Used for the --temp-dir scratch directory and the
--delay-updates staging directory. */