* [PATCH v1] selftests/landlock: Add tests for O_TMPFILE
@ 2026-07-22 15:13 Mickaël Salaün
0 siblings, 0 replies; only message in thread
From: Mickaël Salaün @ 2026-07-22 15:13 UTC (permalink / raw)
To: Günther Noack
Cc: Mickaël Salaün, linux-security-module, Takao Sato,
Willy Tarreau, brauner, viro
open(2) with O_TMPFILE creates its unnamed inode through vfs_tmpfile(),
which, unlike normal file creation, calls neither security_path_mknod()
nor security_inode_create(). It is nonetheless mediated: vfs_tmpfile()
opens the inode through the filesystem's ->tmpfile() operation, which
reaches security_file_open() via finish_open(). The open is therefore
checked like any other, and materializing the file with linkat(2) is
checked like any other link.
Add tests for both paths so O_TMPFILE cannot bypass Landlock.
Cc: Günther Noack <gnoack@google.com>
Signed-off-by: Mickaël Salaün <mic@digikod.net>
---
tools/testing/selftests/landlock/fs_test.c | 251 +++++++++++++++++++++
1 file changed, 251 insertions(+)
diff --git a/tools/testing/selftests/landlock/fs_test.c b/tools/testing/selftests/landlock/fs_test.c
index cdb47fc1fc0a..b826b2c6a8f0 100644
--- a/tools/testing/selftests/landlock/fs_test.c
+++ b/tools/testing/selftests/landlock/fs_test.c
@@ -450,6 +450,25 @@ static int test_open(const char *const path, const int flags)
return test_open_rel(AT_FDCWD, path, flags);
}
+/*
+ * Opens an anonymous O_TMPFILE inode in the directory dir. O_TMPFILE is always
+ * combined with O_WRONLY or O_RDWR, so the caller must pass one of them in
+ * flags.
+ */
+static int test_tmpfile(const char *const dir, const int flags)
+{
+ int fd;
+
+ fd = open(dir, O_TMPFILE | flags | O_CLOEXEC, 0700);
+ if (fd < 0)
+ return errno;
+
+ if (close(fd) != 0)
+ return errno;
+
+ return 0;
+}
+
TEST_F_FORK(layout1, no_restriction)
{
ASSERT_EQ(0, test_open(dir_s1d1, O_RDONLY));
@@ -2140,6 +2159,238 @@ TEST_F_FORK(layout1, link)
ASSERT_EQ(0, link(file1_s1d3, file2_s1d3));
}
+/*
+ * O_TMPFILE does not go through the path_mknod hook: vfs_tmpfile() creates the
+ * inode without calling security_path_mknod(). These tests verify that the
+ * resulting file is still mediated, via the file_open hook, so O_TMPFILE cannot
+ * be used to bypass Landlock.
+ */
+
+/*
+ * An O_TMPFILE open requires WRITE_FILE (and READ_FILE for O_RDWR) on the
+ * directory hierarchy, exactly like any other writable open. It does not
+ * require (nor is it granted by) MAKE_REG: the anonymous inode is not yet a
+ * named file. O_TMPFILE always implies write access, so a read-only request is
+ * rejected by the VFS with EINVAL before Landlock is consulted; Landlock must
+ * not change that into EACCES.
+ */
+TEST_F_FORK(layout1, open_tmpfile)
+{
+ const struct rule rules[] = {
+ /* Write allowed, but neither MAKE_REG nor READ_FILE. */
+ {
+ .path = dir_s1d1,
+ .access = LANDLOCK_ACCESS_FS_WRITE_FILE,
+ },
+ /* Both read and write allowed. */
+ {
+ .path = dir_s1d2,
+ .access = LANDLOCK_ACCESS_FS_READ_FILE |
+ LANDLOCK_ACCESS_FS_WRITE_FILE,
+ },
+ /* File-creation right without write. */
+ {
+ .path = dir_s2d1,
+ .access = LANDLOCK_ACCESS_FS_MAKE_REG,
+ },
+ {},
+ };
+
+ /* Baseline: an unsandboxed O_TMPFILE open works. */
+ EXPECT_EQ(0, test_tmpfile(dir_s1d1, O_WRONLY));
+ EXPECT_EQ(0, test_tmpfile(dir_s2d1, O_RDWR));
+ EXPECT_EQ(0, test_tmpfile(dir_s3d1, O_RDWR));
+
+ /* O_TMPFILE requires write access: read-only is EINVAL at the VFS. */
+ EXPECT_EQ(EINVAL, test_tmpfile(dir_s1d1, O_RDONLY));
+
+ enforce_fs(_metadata,
+ LANDLOCK_ACCESS_FS_READ_FILE |
+ LANDLOCK_ACCESS_FS_WRITE_FILE |
+ LANDLOCK_ACCESS_FS_MAKE_REG,
+ rules);
+
+ /* Write is enough for an O_WRONLY tmpfile; MAKE_REG is not needed. */
+ EXPECT_EQ(0, test_tmpfile(dir_s1d1, O_WRONLY));
+ /* O_RDWR additionally needs READ_FILE, which is absent here. */
+ EXPECT_EQ(EACCES, test_tmpfile(dir_s1d1, O_RDWR));
+
+ /* Read and write allowed: both open modes succeed. */
+ EXPECT_EQ(0, test_tmpfile(dir_s1d2, O_WRONLY));
+ EXPECT_EQ(0, test_tmpfile(dir_s1d2, O_RDWR));
+
+ /* MAKE_REG without WRITE_FILE does not allow the open. */
+ EXPECT_EQ(EACCES, test_tmpfile(dir_s2d1, O_WRONLY));
+ EXPECT_EQ(EACCES, test_tmpfile(dir_s2d1, O_RDWR));
+
+ /* No rule at all: the open is denied. */
+ EXPECT_EQ(EACCES, test_tmpfile(dir_s3d1, O_WRONLY));
+ EXPECT_EQ(EACCES, test_tmpfile(dir_s3d1, O_RDWR));
+
+ /*
+ * A read-only O_TMPFILE stays EINVAL under Landlock, whether the
+ * directory is fully allowed or has no rule: the VFS rejects the flag
+ * combination before the file_open hook, so Landlock never turns it
+ * into EACCES.
+ */
+ EXPECT_EQ(EINVAL, test_tmpfile(dir_s1d2, O_RDONLY));
+ EXPECT_EQ(EINVAL, test_tmpfile(dir_s3d1, O_RDONLY));
+}
+
+/*
+ * When the ruleset handles neither file read nor write access, Landlock has no
+ * opinion on an O_TMPFILE open and must not interfere with it.
+ */
+TEST_F_FORK(layout1, open_tmpfile_unhandled)
+{
+ const struct rule rules[] = {
+ {
+ .path = dir_s1d2,
+ .access = LANDLOCK_ACCESS_FS_READ_DIR,
+ },
+ {},
+ };
+
+ enforce_fs(_metadata, LANDLOCK_ACCESS_FS_READ_DIR, rules);
+
+ EXPECT_EQ(0, test_tmpfile(dir_s1d1, O_WRONLY));
+ EXPECT_EQ(0, test_tmpfile(dir_s1d3, O_RDWR));
+ EXPECT_EQ(0, test_tmpfile(dir_s3d1, O_RDWR));
+}
+
+/*
+ * Materializing an anonymous O_TMPFILE into its creation directory with
+ * linkat(AT_EMPTY_PATH) is gated by MAKE_REG on that directory, even though
+ * obtaining the writable tmpfile only required WRITE_FILE. This is the check
+ * that stops O_TMPFILE from creating a named file where the sandbox forbids
+ * file creation. Linking into the same directory does not involve reparenting,
+ * so REFER is not required.
+ */
+TEST_F_FORK(layout1, link_tmpfile)
+{
+ int fd;
+ const struct rule rules[] = {
+ /* Write only: the tmpfile opens but cannot be linked. */
+ {
+ .path = dir_s1d1,
+ .access = LANDLOCK_ACCESS_FS_WRITE_FILE,
+ },
+ /* Write and MAKE_REG: the tmpfile opens and can be linked. */
+ {
+ .path = dir_s2d1,
+ .access = LANDLOCK_ACCESS_FS_WRITE_FILE |
+ LANDLOCK_ACCESS_FS_MAKE_REG,
+ },
+ {},
+ };
+
+ /* Frees names in the two directories for the new links. */
+ ASSERT_EQ(0, unlink(file1_s1d1));
+ ASSERT_EQ(0, unlink(file1_s2d1));
+
+ enforce_fs(_metadata,
+ LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_MAKE_REG,
+ rules);
+
+ /*
+ * WRITE_FILE is enough to obtain the anonymous tmpfile. linkat(2) with
+ * AT_EMPTY_PATH needs no capability because the fd's open-time
+ * credentials match the caller's. Linking into the same directory does
+ * not require REFER (no reparenting), only MAKE_REG, which is absent
+ * here.
+ */
+ fd = open(dir_s1d1, O_TMPFILE | O_WRONLY | O_CLOEXEC, 0700);
+ ASSERT_LE(0, fd);
+ ASSERT_EQ(-1, linkat(fd, "", AT_FDCWD, file1_s1d1, AT_EMPTY_PATH));
+ EXPECT_EQ(EACCES, errno);
+ EXPECT_EQ(0, close(fd));
+
+ /* With MAKE_REG on the directory, the same link is allowed. */
+ fd = open(dir_s2d1, O_TMPFILE | O_WRONLY | O_CLOEXEC, 0700);
+ ASSERT_LE(0, fd);
+ EXPECT_EQ(0, linkat(fd, "", AT_FDCWD, file1_s2d1, AT_EMPTY_PATH));
+ EXPECT_EQ(0, close(fd));
+}
+
+/*
+ * Linking a tmpfile into a different directory is a reparenting operation: like
+ * any cross-directory link it requires LANDLOCK_ACCESS_FS_REFER. Without it,
+ * materializing the tmpfile outside its creation directory is denied with
+ * EXDEV, so a tmpfile cannot escape its origin hierarchy.
+ */
+TEST_F_FORK(layout1, link_tmpfile_reparent_without_refer)
+{
+ int fd;
+ const struct rule rules[] = {
+ /* Source directory: only the tmpfile open is allowed. */
+ {
+ .path = dir_s1d1,
+ .access = LANDLOCK_ACCESS_FS_WRITE_FILE,
+ },
+ /* Destination directory: file creation is allowed. */
+ {
+ .path = dir_s2d1,
+ .access = LANDLOCK_ACCESS_FS_MAKE_REG,
+ },
+ {},
+ };
+
+ /* Frees a name in the destination directory for the new link. */
+ ASSERT_EQ(0, unlink(file1_s2d1));
+
+ enforce_fs(_metadata,
+ LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_MAKE_REG,
+ rules);
+
+ fd = open(dir_s1d1, O_TMPFILE | O_WRONLY | O_CLOEXEC, 0700);
+ ASSERT_LE(0, fd);
+ /* Cross-directory link without REFER is denied with EXDEV. */
+ ASSERT_EQ(-1, linkat(fd, "", AT_FDCWD, file1_s2d1, AT_EMPTY_PATH));
+ EXPECT_EQ(EXDEV, errno);
+ EXPECT_EQ(0, close(fd));
+}
+
+/*
+ * With LANDLOCK_ACCESS_FS_REFER on both directories, a tmpfile created in one
+ * directory can be linked into another. The destination needs only MAKE_REG
+ * (plus REFER), not WRITE_FILE: the reparenting check compares file access
+ * rights, and the tmpfile gains none by moving to a directory that grants only
+ * the directory-level creation right.
+ */
+TEST_F_FORK(layout1, link_tmpfile_reparent_with_refer)
+{
+ int fd;
+ const struct rule rules[] = {
+ /* Source: tmpfile open (write) and reparenting. */
+ {
+ .path = dir_s1d1,
+ .access = LANDLOCK_ACCESS_FS_WRITE_FILE |
+ LANDLOCK_ACCESS_FS_REFER,
+ },
+ /* Destination: file creation and reparenting, but no write. */
+ {
+ .path = dir_s2d1,
+ .access = LANDLOCK_ACCESS_FS_MAKE_REG |
+ LANDLOCK_ACCESS_FS_REFER,
+ },
+ {},
+ };
+
+ /* Frees a name in the destination directory for the new link. */
+ ASSERT_EQ(0, unlink(file1_s2d1));
+
+ enforce_fs(_metadata,
+ LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_MAKE_REG |
+ LANDLOCK_ACCESS_FS_REFER,
+ rules);
+
+ fd = open(dir_s1d1, O_TMPFILE | O_WRONLY | O_CLOEXEC, 0700);
+ ASSERT_LE(0, fd);
+ /* REFER on both sides plus MAKE_REG on the destination allows it. */
+ EXPECT_EQ(0, linkat(fd, "", AT_FDCWD, file1_s2d1, AT_EMPTY_PATH));
+ EXPECT_EQ(0, close(fd));
+}
+
static int test_rename(const char *const oldpath, const char *const newpath)
{
if (rename(oldpath, newpath))
--
2.54.0
^ permalink raw reply related [flat|nested] only message in thread
only message in thread, other threads:[~2026-07-22 15:14 UTC | newest]
Thread overview: (only message) (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-07-22 15:13 [PATCH v1] selftests/landlock: Add tests for O_TMPFILE Mickaël Salaün
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox