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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user