feat(nixpak): clear env, drop system bus, isolate network (#490)

* fix(nixpak): stop exposing the system D-Bus to apps
* fix(nixpak): bind only the PulseAudio socket
* feat(nixpak): run apps in a new session tied to the launcher
* feat(nixpak): start apps from an allowlisted environment
* feat(nixpak): isolate app networking with pasta
* fix(nixpak): keep Firefox on the host network

Assisted-by: Claude Code (claude-opus-5-5)
This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-10-31 16:39:25 +08:00
1 parent 3a6f03814e
commit 707da35996
5 files changed
+74 -21

No files matched your search

+3 -2
View File
@@ -323,8 +323,9 @@ isolation. Source review and socket metadata—not reads of private credentials
- `ssh -G` reports forwarding for explicit `192.168.*` destinations, but not the sampled `shoryu` - `ssh -G` reports forwarding for explicit `192.168.*` destinations, but not the sampled `shoryu`
alias or GitHub destination. Do not treat forwarding as enabled for every LAN alias, or remove it alias or GitHub destination. Do not treat forwarding as enabled for every LAN alias, or remove it
without checking deployment/build workflows. without checking deployment/build workflows.
- Nixpak apps retain deliberate host networking, GUI/audio/portal/GPU access and selected writable - Nixpak apps retain outbound networking (through pasta, without the host's loopback),
document directories. Firefox additionally retains browserpass/GnuPG integration. These are GUI/audio/portal/GPU access, selected writable document directories, and a read-only view of the
whole `/nix/store`. Firefox additionally retains browserpass/GnuPG integration. These are
filesystem sandboxes, not blanket credential or management-network isolation. filesystem sandboxes, not blanket credential or management-network isolation.
- WeChat retains networking and X11 through its FHS wrapper. Source arguments alone do not prove - WeChat retains networking and X11 through its FHS wrapper. Source arguments alone do not prove
complete runtime isolation. App launch, file chooser, IME, audio/video, GPU and disposable-agent complete runtime isolation. App launch, file chooser, IME, audio/video, GPU and disposable-agent
+11 -1
View File
@@ -21,6 +21,9 @@ let
sloth, sloth,
... ...
}: }:
let
passwordStore = sloth.concat' sloth.homeDir "/.local/share/password-store";
in
{ {
app = { app = {
package = firefox; package = firefox;
@@ -34,6 +37,11 @@ let
./modules/custom-policy.nix ./modules/custom-policy.nix
]; ];
# Firefox keeps the host network: local development servers and the
# mihomo controller live on 127.0.0.1, and pasta's isolated loopback
# would hide them. The other sandboxed apps do not need host loopback.
pasta.enable = lib.mkForce false;
bubblewrap = { bubblewrap = {
# To trace all the home files Firefox accesses, you can use the following nushell command: # To trace all the home files Firefox accesses, you can use the following nushell command:
# just trace-access firefox # just trace-access firefox
@@ -68,7 +76,7 @@ let
# ================ for browserpass extension =============================== # ================ for browserpass extension ===============================
"/etc/gnupg" "/etc/gnupg"
(sloth.concat' sloth.homeDir "/.gnupg") # gpg's config (sloth.concat' sloth.homeDir "/.gnupg") # gpg's config
(sloth.concat' sloth.homeDir "/.local/share/password-store") # my secrets passwordStore # my secrets
(sloth.concat' sloth.runtimeDir "/gnupg") # for access gpg-agent socket (sloth.concat' sloth.runtimeDir "/gnupg") # for access gpg-agent socket
# Match the host's default-app associations and XDG user dirs. # Match the host's default-app associations and XDG user dirs.
@@ -81,6 +89,8 @@ let
# dconf settings. Not verified as needed; candidate for removal. # dconf settings. Not verified as needed; candidate for removal.
(sloth.concat' sloth.xdgConfigHome "/dconf") (sloth.concat' sloth.xdgConfigHome "/dconf")
]; ];
# browserpass looks for the store here, not at pass's default path.
env.PASSWORD_STORE_DIR = passwordStore;
}; };
}; };
}; };
+2 -2
View File
@@ -197,8 +197,8 @@ in
# Desktop-wide mounts (Wayland/at-spi/gvfsd, the app cache, fontconfig and # Desktop-wide mounts (Wayland/at-spi/gvfsd, the app cache, fontconfig and
# Mesa caches, GTK config, /dev/shm) are provided by ./gui-base.nix, so # Mesa caches, GTK config, /dev/shm) are provided by ./gui-base.nix, so
# each path is bound exactly once. This is what keeps a read-only bind # each path is bound exactly once. This is what keeps a read-only bind
# from shadowing a writable one (see the note on the PulseAudio runtime # from shadowing a writable one. Only app-specific mounts belong in this
# dir there). Only app-specific mounts belong in this file. # file.
bind.rw = with sloth; [ bind.rw = with sloth; [
[ [
(mkdir appDataDir) (mkdir appDataDir)
+53 -12
View File
@@ -14,6 +14,9 @@
}: }:
let let
envSuffix = envKey: suffix: sloth.concat' (sloth.env envKey) suffix; envSuffix = envKey: suffix: sloth.concat' (sloth.env envKey) suffix;
# nixpak cannot pass a variable only when it is set, so an unset one arrives
# empty; only list variables whose readers treat empty as unset.
passHostEnv = names: lib.genAttrs names (name: sloth.envOr name "");
# cursor & icon's theme should be the same as the host's one. # cursor & icon's theme should be the same as the host's one.
cursorTheme = pkgs.bibata-cursors; cursorTheme = pkgs.bibata-cursors;
iconTheme = pkgs.papirus-icon-theme; iconTheme = pkgs.papirus-icon-theme;
@@ -43,15 +46,20 @@ in
# Nixpak uses --ro-bind-try / --dev-bind-try: absent host paths are skipped, # Nixpak uses --ro-bind-try / --dev-bind-try: absent host paths are skipped,
# so shared mappings work on Intel/NVIDIA hosts and Apple Silicon alike. # so shared mappings work on Intel/NVIDIA hosts and Apple Silicon alike.
network = lib.mkDefault false; network = lib.mkDefault false;
# setsid keeps the app off the launching terminal (TIOCSTI), and the
# sandbox goes down with the launcher instead of outliving it.
newSession = true;
dieWithParent = true;
# nixpak binds sockets read-only, which is sufficient: connect() only needs # nixpak binds sockets read-only, which is sufficient: connect() only needs
# the socket inode's write bit, and the kernel's read-only-filesystem check # the socket inode's write bit, and the kernel's read-only-filesystem check
# does not apply to sockets. PulseAudio is the exception (see bind.rw). # does not apply to sockets. Every app here is Wayland-only, so X11 stays
# Every app here is Wayland-only, so X11 stays disabled (the nixpak default, # disabled (the nixpak default, spelled out for intent) and PipeWire is
# spelled out for intent) and PipeWire is shared for screensharing. # shared for screensharing.
sockets = { sockets = {
wayland = true; wayland = true;
pipewire = true; pipewire = true;
pulse = true;
x11 = false; x11 = false;
}; };
@@ -66,19 +74,11 @@ in
# gvfsd is a directory that holds FUSE mounts, not a socket, so it must # gvfsd is a directory that holds FUSE mounts, not a socket, so it must
# stay writable. # stay writable.
(envSuffix "XDG_RUNTIME_DIR" "/gvfsd") (envSuffix "XDG_RUNTIME_DIR" "/gvfsd")
# libpulse prepares its runtime dir before connecting (mkdir + chmod 0700),
# so this directory must be writable. nixpak's built-in `sockets.pulse`
# binds it read-only instead, and libpulse then aborts with EROFS when the
# directory is not already 0700 (e.g. pipewire-pulse has not started yet).
# Keep this the only bind of $XDG_RUNTIME_DIR/pulse.
(envSuffix "XDG_RUNTIME_DIR" "/pulse")
]; ];
bind.ro = [ bind.ro = [
# Sockets only need to be connectable, and connect() works on a read-only # Sockets only need to be connectable, and connect() works on a read-only
# bind (see the note on `sockets` above). # bind (see the note on `sockets` above).
(envSuffix "XDG_RUNTIME_DIR" "/at-spi/bus") (envSuffix "XDG_RUNTIME_DIR" "/at-spi/bus")
"/run/dbus"
(sloth.concat' sloth.xdgConfigHome "/gtk-2.0") (sloth.concat' sloth.xdgConfigHome "/gtk-2.0")
(sloth.concat' sloth.xdgConfigHome "/gtk-3.0") (sloth.concat' sloth.xdgConfigHome "/gtk-3.0")
@@ -112,7 +112,23 @@ in
"/tmp" "/tmp"
]; ];
# Start from an empty environment so host secrets (tokens, credentials)
# stay outside; everything the apps need is listed here.
clearEnv = true;
env = { env = {
HOME = sloth.homeDir;
USER = sloth.env "USER";
XDG_RUNTIME_DIR = sloth.runtimeDir;
XDG_CACHE_HOME = sloth.xdgCacheHome;
XDG_CONFIG_HOME = sloth.xdgConfigHome;
XDG_DATA_HOME = sloth.xdgDataHome;
XDG_STATE_HOME = sloth.xdgStateHome;
# Same fallback as nixpak's Wayland socket bind, so they always agree.
WAYLAND_DISPLAY = sloth.envOr "WAYLAND_DISPLAY" "wayland-0";
XDG_SESSION_TYPE = "wayland";
# GTK reads an empty GTK_A11Y as an unknown backend; "0" is its unset value.
GTK_A11Y = sloth.envOr "GTK_A11Y" "0";
XDG_DATA_DIRS = lib.mkForce ( XDG_DATA_DIRS = lib.mkForce (
lib.makeSearchPath "share" [ lib.makeSearchPath "share" [
iconTheme iconTheme
@@ -126,7 +142,32 @@ in
"${cursorTheme}/share/pixmaps" "${cursorTheme}/share/pixmaps"
] ]
); );
}; }
// passHostEnv [
"LANG"
"LANGUAGE"
"LC_ALL"
"LC_ADDRESS"
"LC_COLLATE"
"LC_CTYPE"
"LC_IDENTIFICATION"
"LC_MEASUREMENT"
"LC_MESSAGES"
"LC_MONETARY"
"LC_NAME"
"LC_NUMERIC"
"LC_PAPER"
"LC_TELEPHONE"
"LC_TIME"
"NO_AT_BRIDGE"
"NIXOS_XDG_OPEN_USE_PORTAL"
"NIXOS_OZONE_WL" # QQ's wrapper adds its Wayland flags only when set
"XCURSOR_THEME"
"XCURSOR_SIZE"
"QT_QPA_PLATFORMTHEME"
"QT_WAYLAND_DISABLE_WINDOWDECORATION"
];
}; };
}; };
} }
+5 -4
View File
@@ -1,8 +1,9 @@
# https://github.com/nixpak/pkgs/blob/master/pkgs/modules/network.nix # https://github.com/nixpak/pkgs/blob/master/pkgs/modules/network.nix
{ {
etc.sslCertificates.enable = true; etc.sslCertificates.enable = true;
bubblewrap = { bubblewrap.network = true;
bind.ro = [ "/etc/resolv.conf" ]; # pasta's default isolate mode gives the app its own loopback, so host
network = true; # services on 127.0.0.1 are out of reach. Proxying still works because
}; # mihomo runs in TUN mode, which sees pasta's outbound traffic.
pasta.enable = true;
} }