Auto-expire public/backup exports via a configurable tmpfiles age

HortusFox writes export/auto-backup zips to public/backup using a
fully predictable hf_backup_<timestamp>.zip name, serves that
directory as real static files (ahead of its own auth-gated routes),
and never deletes them itself unless an external backup_path is
separately configured. That leaves full data exports downloadable by
anyone who knows or guesses the timestamp, indefinitely. This can't be
fixed from packaging alone, but giving public/backup's tmpfiles rule
an age (new backupMaxAge option, default "1d", null to disable) lets
systemd-tmpfiles-clean.timer prune old exports and bound the exposure
window. Every other writableDirs entry is unaffected (age stays "-").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 13:54:07 -04:00
co-authored by Claude Sonnet 5
parent 8ec061bede
commit 6f16735747
3 changed files with 36 additions and 1 deletions
+34 -1
View File
@@ -29,6 +29,23 @@ let
"public/img" "public/attachments" "public/backup" "public/themes"
];
# systemd-tmpfiles "age" overrides for specific writableDirs (default: "-",
# never auto-cleaned). public/backup is HortusFox's own export/auto-backup
# destination, named with a fully predictable `hf_backup_<timestamp>.zip`
# pattern (see BackupModule/BackupController upstream) and -- since it
# lives under public/, served as a real static file by nginx/caddy before
# ever reaching the app's own auth-gated routes -- effectively downloadable
# by anyone who knows or guesses that timestamp, indefinitely, since
# upstream never deletes it (the auto-backup cron only does so when an
# external backup_path is also configured in the admin panel; the manual
# "Export" flow never does). We can't fix upstream's public-webroot
# placement or predictable naming from here, but pruning anything older
# than backupMaxAge bounds how long a given export stays fetchable, via
# systemd's own periodic systemd-tmpfiles-clean.timer.
writableDirAges = {
"public/backup" = if cfg.backupMaxAge == null then "-" else cfg.backupMaxAge;
};
# Intermediate parent dirs of the above (e.g. "app", "public") that need
# their own explicit tmpfiles rule too: systemd-tmpfiles auto-vivifies a
# missing parent when processing a nested `d` line, but does so with
@@ -216,6 +233,22 @@ in {
'';
};
backupMaxAge = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "1d";
example = "7d";
description = ''
How long HortusFox's own export/auto-backup zips (written to
public/backup, using systemd-tmpfiles "age" syntax, e.g. "1d",
"12h") are kept before being auto-deleted. HortusFox names these
files with a fully predictable hf_backup_<timestamp>.zip pattern and
serves them from the public webroot without ever cleaning them up
itself, so anyone who knows or guesses the timestamp can download a
full data export indefinitely otherwise. Set to null to disable
cleanup entirely (not recommended for internet-facing deployments).
'';
};
adminAccount.passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
@@ -328,7 +361,7 @@ in {
systemd.tmpfiles.rules =
[ "d ${stateDir} 0755 ${cfg.user} ${cfg.group} - -" ]
++ map (d: "d ${stateDir}/${d} 0755 ${cfg.user} ${cfg.group} - -") writableDirParents
++ map (d: "d ${stateDir}/${d} 0755 ${cfg.user} ${cfg.group} - -") writableDirs;
++ map (d: "d ${stateDir}/${d} 0755 ${cfg.user} ${cfg.group} ${writableDirAges.${d} or "-"} -") writableDirs;
# Kept as one attrset: hortusfox-setup/phpfpm-hortusfox plus the
# cron-generated services all live under systemd.services, and a