Add systemd sandboxing to hortusfox-setup, phpfpm, and cron services

hortusfox-setup and the cron jobs previously ran with no systemd
hardening at all; phpfpm-hortusfox only had whatever services.phpfpm
sets by default (ProtectSystem = "full"). Add a shared conservative
baseline (NoNewPrivileges, Protect*, Restrict*, LockPersonality,
RemoveIPC, RestrictAddressFamilies scoped to AF_UNIX/AF_INET/AF_INET6)
to hortusfox-setup and the cron services, and tighten phpfpm's own
ProtectSystem to "strict" plus an explicit ReadWritePaths for stateDir.

Deliberately no UMask override: a tighter umask silently reintroduces
the "Permission denied" static-asset bug already fixed once, since it
changes the mode cp/mkdir produce for files nginx/caddy (a different
user) need to read directly. Also no MemoryDenyWriteExecute or
SystemCallFilter for now -- broader surface, easy to break something
subtle in migrations/mysql/zip handling without much more testing.

Verified against both vm-tests (full boot + cookie-jar login) for both
proxy backends before landing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 16:52:43 -04:00
co-authored by Claude Sonnet 5
parent e210e344c8
commit 2b1797770f
2 changed files with 60 additions and 2 deletions
+59 -2
View File
@@ -152,6 +152,43 @@ let
cronPort = if cfg.proxy.ssl then 443 else 80;
cronUrl = path: "${scheme}://${cfg.domain}:${toString cronPort}${path}";
# Baseline systemd sandboxing shared by this module's own units
# (hortusfox-setup and the cron jobs; phpfpm-hortusfox gets its own,
# layered on top of what services.phpfpm already sets). Deliberately
# conservative: no MemoryDenyWriteExecute (PHP/Zend may want executable
# heap for opcache) and no SystemCallFilter (broad surface, easy to break
# something subtle in migrations/mysql/zip handling) -- this is a first
# pass at blast-radius reduction, not maximal hardening. RestrictAddress-
# Families keeps AF_INET/AF_INET6 (SMTP, cron's loopback curl) alongside
# AF_UNIX (the mysql socket). Deliberately no UMask override: this
# module's whole design (see the "world-readable 0755" notes elsewhere)
# depends on hortusfox-setup's and phpfpm's default umask producing
# world-readable files/dirs (0755/0644) for nginx/caddy, a different
# user, to traverse and serve statically -- a tighter umask (e.g. 0027)
# silently reintroduces the exact "Permission denied" static-asset bug
# that was already fixed once, since a later `chmod u+w` only adds the
# owner-write bit back and can't restore group/other bits umask stripped
# at creation time.
commonHardening = {
NoNewPrivileges = true;
ProtectHome = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
ProtectClock = true;
ProtectHostname = true;
ProtectProc = "invisible";
ProcSubset = "pid";
RestrictSUIDSGID = true;
RestrictRealtime = true;
RestrictNamespaces = true;
LockPersonality = true;
RemoveIPC = true;
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
};
# curl'd cron endpoints -> (name, path, calendar spec)
cronjobs = {
"tasks-overdue" = { path = "/cronjob/tasks/overdue"; when = "*-*-* 06,18:00:00"; };
@@ -380,7 +417,7 @@ in {
requires = [ "mysql.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
serviceConfig = commonHardening // {
Type = "oneshot";
RemainAfterExit = true;
User = cfg.user;
@@ -392,6 +429,11 @@ in {
# which stays mode 600 regardless of this.
RuntimeDirectory = "hortusfox";
RuntimeDirectoryMode = "0755";
# Whole filesystem read-only except /dev, /proc, /sys and the
# StateDirectory/RuntimeDirectory declared above, which systemd
# auto-exempts -- everything this script writes to (stateDir,
# runDir) is covered by those, so no extra ReadWritePaths needed.
ProtectSystem = "strict";
};
path = [ phpPackage config.services.mysql.package pkgs.coreutils pkgs.gnused ];
@@ -473,17 +515,32 @@ in {
phpfpm-hortusfox = {
after = [ "hortusfox-setup.service" ];
requires = [ "hortusfox-setup.service" ];
# services.phpfpm already sets a reasonable baseline (PrivateTmp,
# ProtectHome, ProtectSystem = "full", RestrictAddressFamilies).
# Tighten ProtectSystem to "strict" (whole filesystem read-only
# except /dev, /proc, /sys and explicit exceptions) -- stateDir is
# the only path phpfpm actually needs to write to, since every
# writable subdir under runDir is a symlink resolving there; runDir
# itself (the app code) only needs to be read, which ProtectSystem
# doesn't restrict. mkForce is needed since services.phpfpm sets
# ProtectSystem = "full" as a plain (non-mkDefault) assignment.
serviceConfig.ProtectSystem = lib.mkForce "strict";
serviceConfig.ReadWritePaths = [ stateDir ];
};
} // (lib.mapAttrs' (name: job:
lib.nameValuePair "hortusfox-cron-${name}" {
description = "HortusFox cronjob: ${name}";
after = [ "${cfg.proxy.server}.service" ];
serviceConfig = {
serviceConfig = commonHardening // {
Type = "oneshot";
User = cfg.user;
ExecStart = "${pkgs.curl}/bin/curl -fsS --max-time 300 "
+ "--resolve ${cfg.domain}:${toString cronPort}:127.0.0.1 "
+ "${cronUrl job.path}";
# A single outbound curl to the app's own loopback address --
# doesn't touch the filesystem at all, so lock it down fully.
ProtectSystem = "strict";
ProtectHome = "tmpfs";
};
}) cronjobs);