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:
@@ -50,6 +50,7 @@ The module (`nixos-module/default.nix`, `config.services.hortusfox-web`) wires H
|
|||||||
- **`adminAccount.passwordFile` actually seeds an admin account.** This was previously a documented no-op; it isn't anymore, because there is no other way to log in at all. `migrate:fresh` creates schema only, no rows. HortusFox has no CLI/seed hook of its own, and its only "create user" flow lives in the admin panel — which requires already being logged in as an admin. So on first boot only (same `.installed` guard as migrations), if this is set, the setup script bcrypt-hashes the file's contents via `php -r '...password_hash(...)...'` (matching `UserModel`'s own hashing) and `INSERT`s a row directly into the `UserModel` table (name `adminAccount.username`, default `"admin"`, purely a display name since HortusFox logs in by email; email `adminAccount.email` — defaults to `admin@<domain>` but is independently overridable, since that default isn't necessarily a mailbox anyone controls and HortusFox's own "Restore password" flow emails it — admin flag set), bypassing the app's normal create-then-promote two-step flow since neither step is reachable yet. Both `vm-test`s exercise a full cookie-jar login against this seeded account, including scraping the CSRF token from `/auth` and checking the final URL isn't `/auth` again — page content alone isn't reliable here, since the dashboard's own (hidden) change-password form makes "does the response contain a password field" a false-positive-prone check.
|
- **`adminAccount.passwordFile` actually seeds an admin account.** This was previously a documented no-op; it isn't anymore, because there is no other way to log in at all. `migrate:fresh` creates schema only, no rows. HortusFox has no CLI/seed hook of its own, and its only "create user" flow lives in the admin panel — which requires already being logged in as an admin. So on first boot only (same `.installed` guard as migrations), if this is set, the setup script bcrypt-hashes the file's contents via `php -r '...password_hash(...)...'` (matching `UserModel`'s own hashing) and `INSERT`s a row directly into the `UserModel` table (name `adminAccount.username`, default `"admin"`, purely a display name since HortusFox logs in by email; email `adminAccount.email` — defaults to `admin@<domain>` but is independently overridable, since that default isn't necessarily a mailbox anyone controls and HortusFox's own "Restore password" flow emails it — admin flag set), bypassing the app's normal create-then-promote two-step flow since neither step is reachable yet. Both `vm-test`s exercise a full cookie-jar login against this seeded account, including scraping the CSRF token from `/auth` and checking the final URL isn't `/auth` again — page content alone isn't reliable here, since the dashboard's own (hidden) change-password form makes "does the response contain a password field" a false-positive-prone check.
|
||||||
- **Serving.** `proxy.server` selects the reverse-proxy backend: `"nginx"` (default) or `"caddy"`, each gated by `lib.mkIf (cfg.proxy.server == "...")`. `proxy.ssl` (default `true`) toggles HTTPS/ACME on the vhost — nginx via `forceSSL`/`enableACME`, caddy by prefixing the site address with `http://` (which disables its automatic HTTPS for that vhost specifically) — set it to `false` for local/test deployments with no real domain or internet access to reach Let's Encrypt (both `vm-test`s and `tests/default.nix` do this). `services.phpfpm.pools.hortusfox` runs as `cfg.user`/`cfg.group` regardless of proxy backend, but its fastcgi socket's `listen.owner`/`listen.group` must match whichever proxy is actually enabled (`config.services.caddy.user`/`.group` vs `config.services.nginx.user`/`.group`) — this was hardcoded to nginx before caddy support existed. nginx uses explicit `tryFiles`/location blocks (an Asatru-framework-style rewrite-to-`index.php` rule, mirroring upstream's `.htaccess`); caddy's `php_fastcgi` directive covers the same rewrite-then-serve behavior natively. Dotfiles are denied on both. When `proxy.ssl = true`, the deploying host config must set nginx's `security.acme.{acceptTerms,defaults.email}` or caddy's `services.caddy.email` globally (see comment at the bottom of the module).
|
- **Serving.** `proxy.server` selects the reverse-proxy backend: `"nginx"` (default) or `"caddy"`, each gated by `lib.mkIf (cfg.proxy.server == "...")`. `proxy.ssl` (default `true`) toggles HTTPS/ACME on the vhost — nginx via `forceSSL`/`enableACME`, caddy by prefixing the site address with `http://` (which disables its automatic HTTPS for that vhost specifically) — set it to `false` for local/test deployments with no real domain or internet access to reach Let's Encrypt (both `vm-test`s and `tests/default.nix` do this). `services.phpfpm.pools.hortusfox` runs as `cfg.user`/`cfg.group` regardless of proxy backend, but its fastcgi socket's `listen.owner`/`listen.group` must match whichever proxy is actually enabled (`config.services.caddy.user`/`.group` vs `config.services.nginx.user`/`.group`) — this was hardcoded to nginx before caddy support existed. nginx uses explicit `tryFiles`/location blocks (an Asatru-framework-style rewrite-to-`index.php` rule, mirroring upstream's `.htaccess`); caddy's `php_fastcgi` directive covers the same rewrite-then-serve behavior natively. Dotfiles are denied on both. When `proxy.ssl = true`, the deploying host config must set nginx's `security.acme.{acceptTerms,defaults.email}` or caddy's `services.caddy.email` globally (see comment at the bottom of the module).
|
||||||
- **`port` also has to override `SERVER_PORT` at the fastcgi layer, not just `APP_URL`.** Asatru's own `url()`/`asset()` helpers (`vendor/danielbrendel/asatru-php-framework/src/helper.php`) never read `APP_URL` at all — they build every absolute URL straight from `$_SERVER['SERVER_NAME']`/`SERVER_PORT`. Nginx's/Caddy's default fastcgi env populates `SERVER_PORT` from the port the proxy itself is listening on (e.g. the QEMU test VM's unforwarded guest port 80), not an externally-reachable/forwarded one (e.g. the hostfwd'd 8080) — so with only `APP_URL` fixed, page HTML still loaded fine (that request itself hits the forwarded port directly) but every asset/CSS/JS URL the app *generates* silently dropped the port, breaking every static asset (`net::ERR_CONNECTION_REFUSED` in the browser, since nothing listens on host port 80). Fixed by explicitly overriding the `SERVER_PORT` fastcgi param/env var to `cfg.port` whenever it's set — nginx via an extra `fastcgi_param SERVER_PORT "...";` line after `include fastcgi_params` (later same-name `fastcgi_param` wins), caddy via `php_fastcgi`'s `env SERVER_PORT ...` sub-directive. Verified by booting `nixosConfigurations.test` manually and confirming assets load over the forwarded port.
|
- **`port` also has to override `SERVER_PORT` at the fastcgi layer, not just `APP_URL`.** Asatru's own `url()`/`asset()` helpers (`vendor/danielbrendel/asatru-php-framework/src/helper.php`) never read `APP_URL` at all — they build every absolute URL straight from `$_SERVER['SERVER_NAME']`/`SERVER_PORT`. Nginx's/Caddy's default fastcgi env populates `SERVER_PORT` from the port the proxy itself is listening on (e.g. the QEMU test VM's unforwarded guest port 80), not an externally-reachable/forwarded one (e.g. the hostfwd'd 8080) — so with only `APP_URL` fixed, page HTML still loaded fine (that request itself hits the forwarded port directly) but every asset/CSS/JS URL the app *generates* silently dropped the port, breaking every static asset (`net::ERR_CONNECTION_REFUSED` in the browser, since nothing listens on host port 80). Fixed by explicitly overriding the `SERVER_PORT` fastcgi param/env var to `cfg.port` whenever it's set — nginx via an extra `fastcgi_param SERVER_PORT "...";` line after `include fastcgi_params` (later same-name `fastcgi_param` wins), caddy via `php_fastcgi`'s `env SERVER_PORT ...` sub-directive. Verified by booting `nixosConfigurations.test` manually and confirming assets load over the forwarded port.
|
||||||
|
- **Systemd sandboxing, and the umask trap.** `hortusfox-setup` and the cron services share a `commonHardening` serviceConfig attrset (NoNewPrivileges, Protect*, Restrict*, LockPersonality, RemoveIPC, RestrictAddressFamilies = `AF_UNIX`/`AF_INET`/`AF_INET6` for the mysql socket and SMTP/cron's loopback curl); `phpfpm-hortusfox` instead tightens `services.phpfpm`'s own baseline (`ProtectSystem` "full" → "strict" via `lib.mkForce`, since the phpfpm module sets it as a plain assignment) plus `ReadWritePaths = [ stateDir ]`. `hortusfox-setup`'s `ProtectSystem = "strict"` needs no extra `ReadWritePaths` since its declared `StateDirectory`/`RuntimeDirectory` are auto-exempted. Deliberately excluded: `UMask` — a tighter umask (e.g. `0027`) looks like obvious hardening, but silently reintroduces the exact "Permission denied" static-asset bug fixed above, since it changes what mode `cp -r`/`mkdir` produce for files hortusfox-setup writes to `runDir`/`stateDir` and phpfpm writes to (e.g. uploaded `public/attachments`) — from world-readable 0755/0644 down to 0750/0640 — and the later `find ... chmod u+w` only adds the owner-write bit back, it can't restore group/other bits umask already stripped at creation time. Also excluded: `MemoryDenyWriteExecute` (PHP/Zend may want executable heap for opcache) and `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, and was verified against both `vm-test`s (full boot + login) before landing.
|
||||||
- **Cron.** HortusFox's cron endpoints (task overdue/tomorrow/recurring reminders, calendar reminders, auto-backup) are driven by `curl`ing the app's own HTTP cron routes from systemd timers (`hortusfox-cron-<name>` service+timer pairs), rather than an in-process scheduler. These live in the *same* `systemd.services` attrset as `hortusfox-setup`/`phpfpm-hortusfox` (merged in with `//`, generated via `lib.mapAttrs'` over `cronjobs`) — keep it that way; splitting the cron services into a second top-level `systemd.services = ...;` binding is a Nix duplicate-attribute error (partial-path assignments like `systemd.services.hortusfox-setup = ...` can't coexist with a later full `systemd.services = ...` reassignment in the same module).
|
- **Cron.** HortusFox's cron endpoints (task overdue/tomorrow/recurring reminders, calendar reminders, auto-backup) are driven by `curl`ing the app's own HTTP cron routes from systemd timers (`hortusfox-cron-<name>` service+timer pairs), rather than an in-process scheduler. These live in the *same* `systemd.services` attrset as `hortusfox-setup`/`phpfpm-hortusfox` (merged in with `//`, generated via `lib.mapAttrs'` over `cronjobs`) — keep it that way; splitting the cron services into a second top-level `systemd.services = ...;` binding is a Nix duplicate-attribute error (partial-path assignments like `systemd.services.hortusfox-setup = ...` can't coexist with a later full `systemd.services = ...` reassignment in the same module).
|
||||||
|
|
||||||
## Current state / known issues
|
## Current state / known issues
|
||||||
|
|||||||
@@ -152,6 +152,43 @@ let
|
|||||||
cronPort = if cfg.proxy.ssl then 443 else 80;
|
cronPort = if cfg.proxy.ssl then 443 else 80;
|
||||||
cronUrl = path: "${scheme}://${cfg.domain}:${toString cronPort}${path}";
|
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)
|
# curl'd cron endpoints -> (name, path, calendar spec)
|
||||||
cronjobs = {
|
cronjobs = {
|
||||||
"tasks-overdue" = { path = "/cronjob/tasks/overdue"; when = "*-*-* 06,18:00:00"; };
|
"tasks-overdue" = { path = "/cronjob/tasks/overdue"; when = "*-*-* 06,18:00:00"; };
|
||||||
@@ -380,7 +417,7 @@ in {
|
|||||||
requires = [ "mysql.service" ];
|
requires = [ "mysql.service" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = commonHardening // {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
User = cfg.user;
|
User = cfg.user;
|
||||||
@@ -392,6 +429,11 @@ in {
|
|||||||
# which stays mode 600 regardless of this.
|
# which stays mode 600 regardless of this.
|
||||||
RuntimeDirectory = "hortusfox";
|
RuntimeDirectory = "hortusfox";
|
||||||
RuntimeDirectoryMode = "0755";
|
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 ];
|
path = [ phpPackage config.services.mysql.package pkgs.coreutils pkgs.gnused ];
|
||||||
@@ -473,17 +515,32 @@ in {
|
|||||||
phpfpm-hortusfox = {
|
phpfpm-hortusfox = {
|
||||||
after = [ "hortusfox-setup.service" ];
|
after = [ "hortusfox-setup.service" ];
|
||||||
requires = [ "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.mapAttrs' (name: job:
|
||||||
lib.nameValuePair "hortusfox-cron-${name}" {
|
lib.nameValuePair "hortusfox-cron-${name}" {
|
||||||
description = "HortusFox cronjob: ${name}";
|
description = "HortusFox cronjob: ${name}";
|
||||||
after = [ "${cfg.proxy.server}.service" ];
|
after = [ "${cfg.proxy.server}.service" ];
|
||||||
serviceConfig = {
|
serviceConfig = commonHardening // {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
User = cfg.user;
|
User = cfg.user;
|
||||||
ExecStart = "${pkgs.curl}/bin/curl -fsS --max-time 300 "
|
ExecStart = "${pkgs.curl}/bin/curl -fsS --max-time 300 "
|
||||||
+ "--resolve ${cfg.domain}:${toString cronPort}:127.0.0.1 "
|
+ "--resolve ${cfg.domain}:${toString cronPort}:127.0.0.1 "
|
||||||
+ "${cronUrl job.path}";
|
+ "${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);
|
}) cronjobs);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user