feat(services): sandbox the wayland session clients

wlsunset, both cliphist units and kanshi each only need the compositor
socket, so the set from the root oneshots applies. 9.4 UNSAFE to 0.9 SAFE,
verified by starting each one under the full set with `systemd-run --user`.

ProtectHome is out because it hides /run/user too, where the socket lives.
stasis gets five directives at most: swaylock authenticates through the
setuid-root unix_chkpwd, and every directive needing a mount namespace makes
`systemd --user` mount it nosuid, so /etc/shadow becomes unreadable.
This commit is contained in:
nevaforget committed 2026-09-08 16:46:53 +02:00
1 parent 76b899a2ad
commit 058ec5c98d
1 file changed
+59 -4
+59 -4
View File
@@ -45,6 +45,46 @@ let
KeyringMode = "private";
UMask = "0077";
};
# Shared by the Wayland session clients below: socket to the compositor,
# nothing else. 9.4 UNSAFE before, 0.9 SAFE after, each one started under the
# full set with `systemd-run --user` first.
#
# Deliberately absent:
# ProtectHome - hides /home, /root *and /run/user*, where the Wayland
# socket lives. Read-only fails too: connect() needs write on the inode.
# PrivateUsers - maps the session uid to nobody, the socket is owned by the
# real one.
waylandClientHardening = {
CapabilityBoundingSet = [ "" ];
AmbientCapabilities = [ "" ];
NoNewPrivileges = true;
ProtectSystem = "strict";
ReadWritePaths = [ "%t" ];
PrivateTmp = true;
PrivateDevices = true;
PrivateNetwork = true;
IPAddressDeny = "any";
RestrictAddressFamilies = [ "AF_UNIX" ];
RemoveIPC = true;
ProtectClock = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectControlGroups = true;
ProtectProc = "invisible";
ProcSubset = "pid";
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
SystemCallArchitectures = "native";
SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
SystemCallErrorNumber = "EPERM";
KeyringMode = "private";
};
in
{
# --- Audio ---
@@ -85,12 +125,15 @@ in
# Without a config kanshi exits with a parse error and, with
# Restart=on-failure, spins. Monitor profiles are per machine.
unitConfig.ConditionPathExists = "%h/.config/kanshi/config";
# The config is read, never written, so ProtectSystem=strict is enough and
# no exception for %h is needed. Not exercised at runtime here: this host
# has no kanshi config, so the condition skips the unit.
serviceConfig = {
Type = "simple";
ExecStart = lib.getExe pkgs.kanshi;
Restart = "on-failure";
RestartSec = 3;
};
} // waylandClientHardening;
};
# --- System services from defaults/etc/systemd/system/ ---
@@ -166,12 +209,24 @@ in
swaylock
brightnessctl
];
# No waylandClientHardening: swaylock is a child of this unit and
# authenticates through the setuid-root unix_chkpwd. `systemd --user` builds
# mount namespaces through an unprivileged user namespace, whose mounts are
# nosuid — the wrapper loses setuid and the session is no longer unlockable.
# Measured per directive with `echo wrong | unix_chkpwd $USER nonull`: exit
# 7 means shadow was read, 9 means no access. Only these five return 7, so
# only they are set. 9.4 → 9.0; there is no more to get here.
serviceConfig = {
Type = "simple";
ExecStart = "${lib.getExe pkgs.stasis}";
ExecReload = "${lib.getExe pkgs.stasis} reload";
Restart = "on-failure";
RestartSec = 2;
IPAddressDeny = "any";
ProtectProc = "invisible";
ProcSubset = "pid";
RemoveIPC = true;
KeyringMode = "private";
};
};
@@ -185,7 +240,7 @@ in
ExecStart = "${pkgs.wl-clipboard}/bin/wl-paste --watch ${pkgs.cliphist}/bin/cliphist -db-path %t/cliphist/db store";
Restart = "on-failure";
RestartSec = 3;
};
} // waylandClientHardening;
};
systemd.user.services.cliphist-image = {
@@ -198,7 +253,7 @@ in
ExecStart = "${pkgs.wl-clipboard}/bin/wl-paste --type image --watch ${pkgs.cliphist}/bin/cliphist -db-path %t/cliphist/db store";
Restart = "on-failure";
RestartSec = 3;
};
} // waylandClientHardening;
};
# Night light stays disabled by default: it is a user toggle. Enabling it
@@ -215,7 +270,7 @@ in
ExecStart = "${lib.getExe pkgs.wlsunset} -T 6500 -t 5000 -S 00:00 -s 00:01";
Restart = "on-failure";
RestartSec = 3;
};
} // waylandClientHardening;
};
# The polkit agent is not a service here: niri spawns it via