How to Sandbox a Terminal
I do most of my work in terminals, using a multitude of small, composable command line tools. During development, I often pull code from remote repositories and execute it for testing. This is an area where sandboxing would be extremely beneficial.
The best option would probably be to explicitly use a sandboxing wrapper every time I execute untrusted code. But that is also tedious. In this post, I want to explore what we can restrict on the terminal as a whole.
Restricting the terminal is tricky because it is a usually thought of as a representation of the user. It should be able to do anything that the user can do. On the other hand, we are already used to the idea that we cannot install packages without using sudo, so maybe we can expand that concept.
Tools at our Disposal
The main mechanism I want to use for this are mount namespaces. They allow us to run the terminal with a completely different file tree. However, I mostly want to keep the existing tree and just hide some files, or make them read-only. The tool that I am using for that is bwrap.
Additionally, many services on a modern Linux desktop use DBus, which means that they all share the same socket. So we also need xdg-dbus-proxy to control access to individual services.
To simplify the usage of both of these tools, I’ve written a wrapper called xiwrap.
Privilege Escalation
We need to prevent processes from escaping the sandbox. There are two DBus services on the session bus that allow unchecked privilege escalations: org.freedesktop.systemd1 (used by systemd-run) and org.freedesktop.portal.Flatpak (used by flatpak-spawn). They should definitely be blocked.
On the other hand, we do want to have an escape hatch to allow users to do things that would normally be prevented by the sandbox. Just like sudo allows us to escalate our privileges by entering a password.
org.freedesktop.systemd1 on the system bus asks for a password for any privileged action, so it is safe to expose. It even provides the run0 command as a drop-in for sudo. We can also use run0 --user $USER to escape the sandbox without becoming root.
I must admit that I didn't understand run0 when it was first announced. But now I really see the appeal of a privilege escalation with user interaction that does not depend on a configuration file in the current mount namespace.1
Of course, a more low-tech option is to add just launch an unsandboxed terminal.
Portals
The DBus services org.freedesktop.portal.Desktop and org.freedesktop.portal.Documents, collectively known as portals, are specifically designed for sandboxing and are generally safe to use. Most features they expose are not particularly relevant for terminals though. The only interface I actually use is org.freedesktop.portal.OpenURI to open files or links in the appropriate applications.
Unfortunately, we are not at a point where portals are used by default. For example, xdg-open will only use the portal if $XDG_RUNTIME_DIR/flatpak-info exists. GTK applications behave differently depending on the contents of /.flatpak-info and whether GIO_USE_PORTALS is set. I am still searching for a setup that works correctly.
This is mostly an issue for the terminal GUI itself though, because, as I said, I rarely use portals from inside the terminal.
Wayland, AT-SPI, Pulse
The system's input and output rely on three main protocols:
- Wayland for video, typically via the socket
$XDG_RUNTIME_DIR/wayland-0 - Pipewire for audio, typically via the sockets
$XDG_RUNTIME_DIR/pipewire-0 - AT-SPI for accessibility, typically via the sockets in
$XDG_RUNTIME_DIR/at-spi
Unfortunately, these protocols do not clearly separate between clients that provide data, and clients that consume data. For example, any client with access to the respective sockets can access the accessibility trees of all other clients, monitor the audio output of other clients, or access the microphone.
Wayland provides some isolation, but is not without flaws either. See my previous post for details.2
There is not much we can do about this until the protocols are changed with security in mind. Until then, I would recommend to not expose the AT-SPI socket in the sandbox unless you need it.
Read-only Path and Config
Allowing untrusted code to install binaries or change configuration is potentially dangerous. At the same time, this is rarely necessary during normal operations. So mounting ~/.config/ and ~/.local/bin read-only is a quick win.
Fixing SSH
Due to the way user namespaces work, root is not mapped inside of the sandbox. This breaks ssh, because it checks that its configuration files are owned by root:
Bad owner or permissions on /etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf
As far as I can tell, /etc/ssh/ssh_config.d/ does not contain anything relevant on my system, so my workaround was simply to bind a tmpfs over it.
Nesting
The setup I am describing here is about applying restrictions on multiple levels:
- The terminal as a whole is restricted
- Within the terminal, I may use a sandboxing wrapper to execute untrusted code
- That code may in turn apply restrictions to its child processes
The kernel models namespaces as a tree structure, which works well with nesting. Unfortunately, some user space protocols, notably DBus and Wayland, use a simpler model where only the app ID of the outermost sandbox is considered. Portals also rely on app IDs to manage permissions.
This model is fundamentally incompatible with the kind of nesting I have in mind. But the community seems to be set on its current approach, so I have little hope of improvement in this area.
Conclusion
Sandboxing on Linux is still incredibly bumpy. Many tools and protocols still need to be adapted. And those that have been adapted often rely on implementation details of Flatpak.
Still, it is possible to cobble together a decent sandbox for the terminal as a whole. I hope this post gave you some inspiration to experiment with your own configuration!
Annex: xiwrap config
At the time of writing, this is the xiwrap config I am using:
setenv USER
setenv HOME
setenv XDG_RUNTIME_DIR
setenv PATH $HOME/.local/bin:/usr/games:/usr/bin
setenv SHELL
include locale
ro-bind /usr
ro-bind /etc
ro-bind /var
# usr-merge symlinks
ro-bind-try /bin
ro-bind-try /lib
ro-bind-try /lib32
ro-bind-try /lib64
dev /dev
proc /proc
tmpfs /tmp
tmpfs $XDG_RUNTIME_DIR
bind $HOME
ro-bind-try $HOME/.config
ro-bind-try $HOME/.local/bin
share-net
ro-bind-try $XDG_RUNTIME_DIR/$WAYLAND_DISPLAY
setenv WAYLAND_DISPLAY
setenv XDG_CURRENT_DESKTOP
setenv XDG_SEAT
setenv XDG_SESSION_CLASS
setenv XDG_SESSION_ID=1
setenv XDG_SESSION_TYPE
ro-bind-try $XDG_RUNTIME_DIR/pipewire-0
dbus-talk org.freedesktop.portal.Desktop
dbus-talk org.freedesktop.portal.Documents
# allow to use run0 and systemctl
dbus-system-talk org.freedesktop.systemd1
dbus-system-talk org.freedesktop.login1
ro-bind /run/systemd
# ignore ssh config with wrongly mapped owner
tmpfs /etc/ssh/ssh_config.d
Another, simpler implementation of this concept can be found in s6.↩︎
Pipewire seems to have a security contexts extension that is closely modelled after that of Wayland.↩︎