aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMattias Andrée <m@maandree.se>2026-11-02 19:16:28 +0100
committerMattias Andrée <m@maandree.se>2026-11-02 19:16:28 +0100
commitec7bbbf0a56ceebd4ec763360520acd2c16e24c2 (patch)
tree0e1540cf792960ae49587564db7bc7a38fa10cf9
downloadfsshield-4cb3bc59d4bbcf949b3f8d43233a32e77205a56a.tar.gz
fsshield-4cb3bc59d4bbcf949b3f8d43233a32e77205a56a.tar.bz2
fsshield-4cb3bc59d4bbcf949b3f8d43233a32e77205a56a.tar.xz
First commitHEAD1.0master
Signed-off-by: Mattias Andrée <m@maandree.se>
Diffstat (limited to '')
-rw-r--r--.gitignore19
-rw-r--r--LICENSE15
-rw-r--r--Makefile44
-rw-r--r--README68
-rw-r--r--config-coverage-gcc.mk14
-rw-r--r--config.mk18
-rw-r--r--fsshield.1145
-rw-r--r--fsshield.c323
8 files changed, 646 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7c691fe
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,19 @@
+*\#*
+*~
+*.o
+*.a
+*.t
+*.f
+*.lo
+*.to
+*.fo
+*.su
+*.so
+*.so.*
+*.dll
+*.dylib
+*.gch
+*.gcov
+*.gcno
+*.gcda
+/fsshield
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..1634eae
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+© 2026 Mattias Andrée <m@maandree.se>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..77dae54
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,44 @@
+.POSIX:
+
+CONFIGFILE = config.mk
+include $(CONFIGFILE)
+
+OBJ =\
+ fsshield.o
+
+HDR =
+
+
+all: fsshield
+$(OBJ): $(HDR)
+
+.c.o:
+ $(CC) -c -o $@ $< $(CFLAGS) $(GOV_CFLAGS) $(CPPFLAGS) $(GOV_CPPFLAGS)
+
+fsshield: $(OBJ)
+ $(CC) -o $@ $(OBJ) $(LDFLAGS) $(COV_LDFLAGS)
+ $(BUILD_SUDO) sh -c 'chown -- 0:0 $@ && chmod -- 4755 $@'
+
+install: fsshield
+ mkdir -p -- "$(DESTDIR)$(PREFIX)/bin"
+ mkdir -p -- "$(DESTDIR)$(MANPREFIX)/man1/"
+ cp -- fsshield "$(DESTDIR)$(PREFIX)/bin/"
+ cp -- fsshield.1 "$(DESTDIR)$(MANPREFIX)/man1/"
+
+post-install:
+ chown -- '0:0' "$(DESTDIR)$(PREFIX)/bin/fsshield"
+ chmod -- 4755 "$(DESTDIR)$(PREFIX)/bin/fsshield"
+
+uninstall:
+ -rm -f -- "$(DESTDIR)$(PREFIX)/bin/fsshield"
+ -rm -f -- "$(DESTDIR)$(MANPREFIX)/man1/fsshield.1"
+
+clean:
+ -rm -f -- *.o *.a *.lo *.su *.so *.so.* *.gch
+ -rm -f -- *.gcov *.gcno *.gcda *.t *.to *.f *.fo
+ -rm -f -- fsshield
+
+.SUFFIXES:
+.SUFFIXES: .o .c
+
+.PHONY: all install uninstall post-install clean
diff --git a/README b/README
new file mode 100644
index 0000000..c80f30f
--- /dev/null
+++ b/README
@@ -0,0 +1,68 @@
+NAME
+ fsshield - Limit a new process's access to the filesystem
+
+SYNOPSIS
+ fsshield [-a file] ... [-r file] ... [-t file] ... [-nR] utility [argument] ...
+
+DESCRIPTION
+ The fsshield utility runs a specified utility, but limits it's access to
+ the filesystem. By default it gives read-only access to some fundamental
+ directories and /etc/passwd, access to the current working directory, and
+ clean tmpfs mounts on /tmp and /var/tmp.
+
+ In this man page, "read-only" means that a bind-mount with the read-only
+ option is created. Other accessible files are normal bind-mounts. The
+ permissions on the files are not modified.
+
+OPTIONS
+ The following options are supported:
+
+ -a file
+ Make file accessible.
+
+ -r file
+ Make file accessible but read-only.
+
+ -t file
+ Mount a tmpfs on file.
+
+ -n
+ Do not apply the preset file accesses.
+
+ -R
+ Make the current working directory read-only.
+
+ The order of options do not matter, however -a overrides -r and -t,
+ while -r overrides -t. The preset files accesses are
+ -t /tmp -t /var/tmp -r /var/empty -r /etc/passwd -r /usr -r /bin
+ -r /sbin -r /lib -r /lib64 -r /lib32 -r /libexec, however limited
+ to files that exist (including broken symlinks). Additionally
+ the current working directory is always accessible (-a ., or
+ if -R is used: -r .), regardless of the use of -n.
+
+OPERANDS
+ The following operands are supported:
+
+ utility
+ The name of the utility to be invoked.
+
+ argument
+ A string to pass as an argument for the invoked utility.
+
+EXIT STATUS
+ If utility is invoked, the exit status of fsshield is the exit status
+ of utility; otherwise, the fsshield utility exits with one of the
+ following values:
+
+ 125 An error occurred in the fsshield utility.
+
+ 126 The utility specified by utility was found but could not be invoked.
+
+ 127 The utility specified by utility could not be found.
+
+NOTES
+ The files can be any type of file, including directories, not just
+ regular files.
+
+SEE ALSO
+ None.
diff --git a/config-coverage-gcc.mk b/config-coverage-gcc.mk
new file mode 100644
index 0000000..60f81f8
--- /dev/null
+++ b/config-coverage-gcc.mk
@@ -0,0 +1,14 @@
+CONFIGFILE_PROPER = config.mk
+include $(CONFIGFILE_PROPER)
+
+CC = $(CC_PREFIX)gcc -std=c99
+GCOV = gcov
+
+COV_CPPFLAGS = -DCOVERAGE_TEST
+COV_CFLAGS = --coverage -g -O0
+COV_LDFLAGS = --coverage -g -O0
+
+G =
+
+coverage: check
+ $(GCOV) -pr -- *.gcda 2>&1
diff --git a/config.mk b/config.mk
new file mode 100644
index 0000000..f0726da
--- /dev/null
+++ b/config.mk
@@ -0,0 +1,18 @@
+PREFIX = /usr
+MANPREFIX = $(PREFIX)/share/man
+
+CC = c99
+
+COMMON_SANITIZE = -fsanitize=alignment,shift,signed-integer-overflow,object-size,null,undefined,bounds,address
+CLANG_SANITIZE = -O1 $(COMMON_SANITIZE),cfi -flto -fvisibility=hidden -fno-sanitize-trap=cfi
+GCC_SANITIZE = -O1 $(COMMON_SANITIZE)
+#SANITIZE = $(CLANG_SANITIZE)
+#SANITIZE = $(GCC_SANITIZE)
+
+CPPFLAGS = -D_DEFAULT_SOURCE -D_BSD_SOURCE -D_XOPEN_SOURCE=700 -D_GNU_SOURCE
+CFLAGS = $(SANITIZE)
+LDFLAGS = $(SANITIZE) -lsimple
+
+G = -g
+
+BUILD_SUDO = @:
diff --git a/fsshield.1 b/fsshield.1
new file mode 100644
index 0000000..0d1ce9f
--- /dev/null
+++ b/fsshield.1
@@ -0,0 +1,145 @@
+.TH FSSHIELD 1 FSSHIELD
+.SH NAME
+fsshield \- Limit a new process's access to the filesystem
+
+.SH SYNOPSIS
+.B fsshield
+.RB [ -a
+.IR file ]\ ...\,
+.BR [ -r
+.IR file ]\ ...\,
+.RB [ -t
+.IR file ]\ ...\,
+.RB [ -nR ]
+.I utility
+.RI [ argument ]\ ...
+
+.SH DESCRIPTION
+The
+.B
+fsshield utility runs a specified utility, but limits it's access to
+the filesystem. By default it gives read-only access to some fundamental
+directories and
+.IR /etc/passwd ,
+access to the current working directory, and
+clean tmpfs mounts on
+.I /tmp
+and
+.IR /var/tmp .
+
+In this man page, \(dqread-only\(dq means that a bind-mount with the read-only
+option is created. Other accessible files are normal bind-mounts. The
+permissions on the files are not modified.
+
+.SH OPTIONS
+The following options are supported:
+.TP
+.BR -a \ \fIfile\fR
+Make
+.I file
+accessible.
+.TP
+.BR -r \ \fIfile\fR
+Make
+.I file
+accessible but read-only.
+.TP
+.BR -t \ \fIfile\fR
+Mount a tmpfs on
+.IR file .
+.TP
+.B -n
+Do not apply the preset file accesses.
+.TP
+.B -R
+Make the current working directory read-only.
+.PP
+The order of options do not matter, however
+.B -a
+overrides
+.B -r
+and
+.BR -t ,
+while
+.B -r
+overrides
+.BR -t .
+The preset files accesses are
+.B -t
+.I /tmp
+.B -t
+.I /var/tmp
+.B -r
+.I /var/empty
+.B -r
+.I /etc/passwd
+.B -r
+.I /usr
+.B -r
+.I /bin
+.B -r
+.I /sbin
+.B -r
+.I /lib
+.B -r
+.I /lib64
+.B -r
+.I /lib32
+.B -r
+.IR /libexec ,
+however limited
+to files that exist (including broken symlinks). Additionally
+the current working directory is always accessible
+.RB ( -a
+.IR . ,
+or if
+.B -R
+is used:
+.B -r
+.IR . ),
+regardless of the use of
+.BR -n .
+
+.SH OPERANDS
+The following operands are supported:
+.TP
+.I utility
+The name of the utility to be invoked.
+.TP
+.I argument
+A string to pass as an argument for the invoked utility.
+
+.SH EXIT STATUS
+If
+.I utility
+is invoked, the exit status of
+.B fsshield
+is the exit status of
+.IR utility ;
+otherwise, the
+.B fsshield
+utility exits with one of the following values:
+.TP
+125
+An error occurred in the
+.B fsshield
+utility.
+.TP
+126
+The utility specified by
+.I utility
+was found but could not be invoked.
+.TP
+127
+The utility specified by
+.I utility
+could not be found.
+
+.SH NOTES
+The
+.IR file s
+can be any type of file, including directories,
+not just regular files.
+
+.SH SEE ALSO
+None.
diff --git a/fsshield.c b/fsshield.c
new file mode 100644
index 0000000..4518e4d
--- /dev/null
+++ b/fsshield.c
@@ -0,0 +1,323 @@
+/* See LICENSE file for copyright and license details. */
+#include <sys/mount.h>
+#include <sys/syscall.h>
+#include <sched.h>
+#include <libsimple.h>
+#include <libsimple-arg.h>
+
+NUSAGE(125, "[-a file] ... [-r file] ... [-t file] ... [-nR] utility [argument] ...");
+
+
+enum type {
+ ACCESSIBLE,
+ READONLY,
+ TMPFS
+};
+
+struct file {
+ const char *path;
+ enum type type;
+};
+
+
+static struct file *files = NULL;
+static size_t nfiles = 0u;
+
+static const struct file preset[] = {
+ {".", ACCESSIBLE},
+ {"/tmp", TMPFS},
+ {"/var/tmp", TMPFS},
+ {"/var/empty", READONLY},
+ {"/etc/passwd", READONLY},
+ {"/usr", READONLY},
+ {"/bin", READONLY},
+ {"/sbin", READONLY},
+ {"/lib", READONLY},
+ {"/lib64", READONLY},
+ {"/lib32", READONLY},
+ {"/libexec", READONLY}
+};
+
+static size_t npreset = ELEMSOF(preset);
+
+static char *cwd;
+
+
+static void
+add(const char *path, enum type type)
+{
+ if (!*path)
+ usage();
+ files[nfiles].path = path;
+ files[nfiles].type = type;
+ nfiles += 1u;
+}
+
+
+static void
+make_dirs(char *path, mode_t mode, int make_top)
+{
+ char *p;
+ for (p = &path[1u]; *p; p++) {
+ if (*p != '/')
+ continue;
+ *p = '\0';
+ if (mkdir(path, mode) && errno != EEXIST)
+ eprintf("mkdir %s %03o:", path, mode);
+ *p = '/';
+ }
+ if (make_top)
+ if (mkdir(path, mode) && errno != EEXIST)
+ eprintf("mkdir %s %03o:", path, mode);
+}
+
+
+static void
+touch(const char *path, mode_t mode)
+{
+ int fd;
+ if (!faccessat(AT_FDCWD, path, F_OK, AT_SYMLINK_NOFOLLOW))
+ return;
+ fd = open(path, O_CREAT | O_WRONLY, mode);
+ if (fd < 0)
+ eprintf("open %s O_CREAT|O_WRONLY %03o:", path, mode);
+ close(fd);
+}
+
+
+static char *
+clean_path(const char *path)
+{
+ char *ret, *r, *w;
+
+ if (*path != '/') {
+ ret = emalloc(strlen(cwd) + strlen(path) + sizeof("/"));
+ stpcpy(stpcpy(stpcpy(ret, cwd), "/"), path);
+ } else {
+ ret = estrdup(path);
+ }
+
+ r = w = &ret[1u];
+
+ while (*r) {
+ while (*r == '/') r++;
+ if (r[0u] == '.' && r[1u] == '/') {
+ r = &r[2u];
+ } else if (r[0u] == '.' && !r[1u]) {
+ break;
+ } else if (r[0u] == '.' && r[1u] == '.' && (r[2u] == '/' || !r[2u])) {
+ if (--w != ret)
+ while (*w != '/')
+ w--;
+ w++;
+ r = &r[2u];
+ if (!*r++)
+ break;
+ } else {
+ do {
+ *w++ = *r;
+ } while (*r && *r++ != '/');
+ }
+ }
+
+ if (w[-1] == '/' && w != &ret[1u])
+ w--;
+
+ *w = '\0';
+
+ if (!strcmp(ret, "/.oldroot"))
+ eprintf("/.oldroot must not be included as an accessible path");
+
+ return ret;
+}
+
+
+static int
+cmpfiles(const void *av, const void *bv)
+{
+ const struct file *a = av;
+ const struct file *b = bv;
+ int cmp = strcmp(a->path, b->path);
+ return cmp ? cmp : (int)a->type - (int)b->type;
+}
+
+
+static int
+isdir(const char *path)
+{
+ struct stat st;
+ return stat(path, &st) || S_ISDIR(st.st_mode);
+}
+
+
+static void
+setup_filesystem(void)
+{
+ struct mount_attr attr, ro = {.attr_set = MOUNT_ATTR_RDONLY};
+ char root[] = "/tmp/fsshield.XXXXXX";
+ char *pathbuf;
+ size_t i, len, maxlen = sizeof("/.oldroot") - 1u;
+ int fds[2];
+ pid_t pid;
+
+ if (socketpair(PF_LOCAL, SOCK_STREAM | SOCK_CLOEXEC, 0, fds))
+ eprintf("socketpair PF_LOCAL SOCK_STREAM|SOCK_CLOEXEC 0:");
+
+ if (!mkdtemp(root))
+ eprintf("mkdtemp:");
+
+ pid = fork();
+ switch (pid) {
+ case -1:
+ eprintf("fork:");
+ case 0:
+ close(fds[1]);
+ pid = fork();
+ switch (pid) {
+ case -1:
+ eprintf("fork:");
+ case 0:
+ if (write(fds[0], &(char){0}, 1u) != 1)
+ eprintf("write <socket>:");
+ if (read(fds[0], &(char){0}, 1u) != 0)
+ eprintf("read <socket>:");
+ close(fds[0]);
+ if (rmdir(root))
+ weprintf("rmdir %s:", root);
+ break;
+ default:
+ break;
+ }
+ exit(0);
+ default:
+ close(fds[0]);
+ switch (read(fds[1], &(char){0}, 1u)) {
+ case 0:
+ exit(125);
+ case 1:
+ break;
+ default:
+ eprintf("read <socket>:");
+ }
+ waitpid(pid, NULL, 0);
+ break;
+ }
+
+ for (i = 0u; i < nfiles; i++) {
+ len = strlen(files[i].path);
+ maxlen = MAX(maxlen, len);
+ }
+ pathbuf = emalloc(maxlen + sizeof(root));
+
+ if (unshare(CLONE_NEWNS))
+ eprintf("unshare CLONE_NEWNS:");
+
+ if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL))
+ eprintf("mount NULL / NULL MS_REC|MS_PRIVATE NULL:");
+
+ if (mount("tmpfs", root, "tmpfs", MS_NOSUID | MS_NODEV, "mode=0755"))
+ eprintf("mount tmpfs %s tmpfs MS_NOSUID|MS_NODEV mode=0755:", root);
+
+ for (i = 0u; i < nfiles; i++) {
+ stpcpy(stpcpy(pathbuf, root), files[i].path);
+ switch (files[i].type) {
+ case ACCESSIBLE:
+ case READONLY:
+ if (isdir(files[i].path)) {
+ make_dirs(pathbuf, 0755, 1);
+ if (mount(files[i].path, pathbuf, NULL, MS_BIND | MS_REC, NULL))
+ eprintf("mount %s %s NULL MS_BIND|MS_REC NULL:", files[i].path, pathbuf);
+ } else {
+ make_dirs(pathbuf, 0755, 0);
+ touch(pathbuf, 0644);
+ if (mount(files[i].path, pathbuf, NULL, MS_BIND, NULL))
+ eprintf("mount %s %s NULL MS_BIND NULL:", files[i].path, pathbuf);
+ }
+ if (files[i].type == READONLY) {
+ attr = ro;
+ if (mount_setattr(AT_FDCWD, pathbuf, AT_RECURSIVE, &attr, sizeof(attr)))
+ eprintf("mount_setattr AT_FDCWD %s AT_RECURSIVE {.attr_set = MOUNT_ATTR_RDONLY}:", pathbuf);
+ }
+ break;
+ case TMPFS:
+ make_dirs(pathbuf, 01777, 1);
+ if (mount("tmpfs", pathbuf, "tmpfs", MS_NOSUID | MS_NODEV, "mode=1777"))
+ eprintf("mount tmpfs %s tmpfs MS_NOSUID|MS_NODEV mode=1777:", pathbuf);
+ break;
+ default:
+ abort();
+ }
+ }
+
+ stpcpy(stpcpy(pathbuf, root), "/.oldroot");
+ make_dirs(pathbuf, 0700, 1);
+ if (chdir(root))
+ eprintf("chdir %s:", root);
+ if (syscall(SYS_pivot_root, ".", ".oldroot"))
+ eprintf("pivot_root . .oldroot (from %s):", root);
+ if (chdir(cwd))
+ eprintf("chdir %s:", cwd);
+ if (umount2("/.oldroot", MNT_DETACH))
+ eprintf("umount2 /.oldroot MNT_DETACH");
+ if (rmdir("/.oldroot"))
+ eprintf("rmdir /.oldroot");
+ free(pathbuf);
+}
+
+
+int
+main(int argc, char *argv[])
+{
+ size_t i, n;
+ enum type cwd_access = ACCESSIBLE;
+
+ libsimple_default_failure_exit = 125;
+
+ argv0 = argv[0];
+ files = ecalloc((size_t)argc + npreset, sizeof(*files));
+
+ ARGBEGIN {
+ case 'a': add(ARG(), ACCESSIBLE); break;
+ case 'r': add(ARG(), READONLY); break;
+ case 't': add(ARG(), TMPFS); break;
+ case 'n': npreset = 1u; break;
+ case 'R': cwd_access = READONLY; break;
+ default:
+ usage();
+ } ARGEND;
+
+ if (!argc)
+ usage();
+
+ files[nfiles] = preset[0u];
+ files[nfiles++].type = cwd_access;
+ for (i = 1u; i < npreset; i++)
+ if (!faccessat(AT_FDCWD, preset[i].path, F_OK, AT_SYMLINK_NOFOLLOW))
+ files[nfiles++] = preset[i];
+
+ cwd = get_current_dir_name();
+ if (!cwd)
+ eprintf("get_current_dir_name:");
+ if (*cwd != '/')
+ abort();
+
+ for (i = 0u; i < nfiles; i++)
+ files[i].path = clean_path(files[i].path);
+
+ qsort(files, nfiles, sizeof(*files), &cmpfiles);
+ for (i = n = 1u; i < nfiles; i++)
+ if (strcmp(files[i].path, files[n - 1u].path))
+ files[n++] = files[i];
+ nfiles = n;
+
+ setup_filesystem();
+ unsetenv("PWD");
+
+ if (setegid(getgid()))
+ eprintf("setegid <real group>:");
+ if (seteuid(getuid()))
+ eprintf("seteuid <real user>:");
+
+ execvp(argv[0], argv);
+ enprintf(errno == ENOENT ? 127 : 126, "execvp %s:", argv[0]);
+}