Skip to content

Nextcloud: hardening, Unix sockets for Redis/Valkey, and credentials out of reach - #396

Open
markuslf wants to merge 38 commits into
mainfrom
fix/nextcloud
Open

markuslf wants to merge 38 commits into
mainfrom
fix/nextcloud

Conversation

@markuslf

Copy link
Copy Markdown
Member

Follows the Nextcloud hardening guide and brings the nextcloud role up to the state the wordpress role reached, after comparing a hand-built production installation against what setup_nextcloud deploys.

What changes

Redis / Valkey over a Unix socket. Nextcloud talked to the cache over TCP on the loopback. The redis and valkey roles now open a socket at 0770 owned by their own group, the nextcloud role adds the web server user to that group, and setup_nextcloud configures SELinux before the services, since on RHEL 10 the shipped policy does not know Valkey yet and the daemon would otherwise keep running unconfined.

A dedicated database user. The installer received the database administrator and created an oc_ user that could connect from any host. nextcloud__database_login is now mandatory and setup_nextcloud creates that user with access from localhost only. Existing installations keep their user.

Requirements are checked before anything is touched. The role reads the installed Nextcloud, PHP and MariaDB versions and aborts with a message naming what to upgrade. The table covers Nextcloud 30 to 35.

The PHP module list matches what Nextcloud actually loads. APCu, bcmath, IMAP and memcached are removed: every cache lives in Redis or Valkey, and the remaining entries carry a comment saying why they are there. Checked against OC_Util::checkServer() and apps/settings/lib/SetupChecks/PhpModules.php.

Credentials no longer sit where local users can read them. Passwords reach occ on stdin instead of the command line, the Icinga API credentials move out of /etc/profile.d into a root-only script, and borg-backup, do-reboot, schedule-icinga-downtime and nextcloud-update are 0700. The borg_local downtime request also sent malformed JSON, which Icinga rejected with a 400.

fail2ban, opt-in. Behind a reverse proxy a ban would hit the proxy, so setup_nextcloud__skip_fail2ban defaults to true. Where it is switched on, the jail reads X-Forwarded-For and the proxies listed in the Nextcloud setting trusted_proxies are never banned. The log moves to /var/log/nextcloud/, because SELinux does not let fail2ban read the data directory.

Smaller fixes. The SMTP certificate is verified, the LDAP remnants report runs from its own timer, nextcloud-update adds missing primary keys and runs the expensive repairs, the data directory gets its ownership and SELinux label, and a failed installer attempt is retried instead of being skipped forever.

Breaking changes

Twelve entries under ### Breaking Changes in the CHANGELOG, each naming the variable to set to keep the previous behaviour. The ones that need action in the inventory before a run: nextcloud__version and nextcloud__database_login are mandatory now.

Testing

Every run was made against a Rocky 10 VM with Nextcloud 35, PHP 8.5 from Remi, MariaDB and Valkey, from an empty machine to a working installation, and repeated until a second run reported no change. The Molecule scenarios for valkey, schedule_reboot, fail2ban and setup_nextcloud were extended to assert the socket, the config ownership, the root-only scripts and the fail2ban counting through X-Forwarded-For. pre-commit run --all-files is clean, ansible-lint reports only findings that predate this branch.

Not tested: RHEL 8 and 9 with Redis from Remi, Debian and Ubuntu.

nextcloud-ldap-show-remnants.timer pointed to nextcloud-app-update.service,
so the monthly report never ran and the apps were updated once more instead.
…_datadir

mkdir, restorecon and the httpd_sys_rw_content_t fcontext were hardcoded to
/data, so a different nextcloud__datadir got neither.
…irs in nextcloud-update

nextcloud-app-update already ran db:add-missing-primary-keys, the server
update did not. maintenance:repair --include-expensive applies the mimetype
migrations that `occ setupchecks` reports after an upgrade, since Nextcloud
leaves them to the administrator.
--database-pass and --admin-pass were readable in the process list. Without
them occ maintenance:install asks for the database password first and the
admin password second, one line of stdin each when there is no TTY (verified
with Nextcloud 35.0.0 on Rocky 10, PHP 8.3 and 8.5). The stdin is a block
scalar: '{{ a ~ "\n" ~ b }}' in single quotes reaches the command as a
literal backslash-n with ansible-core 2.16.
The script carries the credentials of the Icinga API user and was deployed
with 0755. Only root can run it anyway, since it restarts services and
switches to the web server user.
The role set mail_smtpstreamoptions ssl allow_self_signed=true,
verify_peer=false and verify_peer_name=false on every host. The entries are
now state: absent, so existing hosts get them removed; the empty 'ssl' array
left behind is a no-op, since the Mailer merges the options into its own
(verified against Nextcloud 35).
…xtcloud__app_configs

The force and state subkeys of nextcloud__apps were listed under
nextcloud__app_configs, and nextcloud__apps claimed present/absent with a
default of present, while the task defaults to enabled and the module knows
four states. Also drop the trailing spaces from the keys of the reverse proxy
example.
…ud forbids anyway

'\n', '\r' and '\u0000' in single quotes reached Nextcloud as literal
backslash sequences, not as control characters. Nextcloud always forbids the
backslash and the characters 0 to 31 (lib/private/Files/FilenameValidator.php,
OCP\Constants::FILENAME_INVALID_CHARS), so the entries never had an effect.
'|' moves up to index 6 and indexes 7 to 9 are removed from existing hosts.
The filter is the one from the Nextcloud hardening guide. Verified with
fail2ban-regex 1.1.0 on Rocky 10 against the log of Nextcloud 35.0.0: failed
logins via web, WebDAV and OCS, disabled accounts, failed two-factor
challenges, IPv4 and IPv6. The trusted domain error is logged at info level
and only matches with loglevel 1 or lower. The regular expressions contain
'{%', so the template renders them raw.
fail2ban_t may read files of the logfile attribute, but not
httpd_sys_rw_content_t in the data directory: the jail found no log file
until fail2ban_t was made permissive (fail2ban 1.1.0 with fail2ban-selinux,
selinux-policy 42.1.18 on Rocky 10). /var/log/nextcloud is labeled
httpd_log_t, where httpd_t may create and append, and the log file is created
by the role, since Nextcloud silently falls back to the data directory if it
cannot create it itself. Rotation runs in nextcloud-jobs.service, outside
httpd_t.
…he trusted proxies

fail2ban belongs on hosts that clients reach directly, so it stays off by
default (setup_nextcloud__skip_fail2ban). When enabled, the playbook runs it
after nextcloud, since fail2ban does not start while the log of a jail is
missing, and injects the nextcloud jail. The addresses from the trusted_proxies
entries of nextcloud__sysconfig go to the new
fail2ban__jail_default_ignoreip__dependent_var, so no jail bans the proxy.
…alled Nextcloud

The requirements per major version come from the server source at the
release tags v30.0.0 to v35.0.0: the PHP range from lib/versioncheck.php,
outside of which Nextcloud refuses to run, and MIN_MARIADB from
apps/settings/lib/SetupChecks/SupportedDatabase.php. The major version is read
from version.php of the unpacked code, since nextcloud__version may just say
'latest'. Nextcloud sets no minimum for the Redis or Valkey server, only
phpredis >= 4.0.0.
… use

The RHEL package enables /run/valkey/valkey.sock, the templates commented it
out. valkey__conf_unixsocket follows the package path on RHEL and uses
/run/valkey/valkey-server.sock on Debian and Ubuntu, whose package ships the
socket off. valkey__conf_unixsocketperm defaults to 770: without it the mode
follows the umask of the service (0755), so only valkey itself could connect.
valkey__conf_unixsocket stays out of argument_specs, since its default comes
from vars/<os>.yml, which is loaded after the role-entry validation.
Same as for Valkey. The Remi package for EL9 (redis 8.8) enables
/run/redis/redis.sock with the mode left to the umask. Redis has no
unixsocketgroup (Valkey added it in 8.0), so access goes through the redis
group on both.
Derived from the valkey role, whose variables and static defaults are the
same; checked with validate_argument_spec against every static default of
the role.
Installing the policy build dependencies of the selinux role can update the
SELinux policy, and a daemon started under the older policy keeps its domain
until it is restarted. On a Rocky 10.0 host (selinux-policy 40.13.26), the
first start of Valkey labeled /usr/bin/valkey-server bin_t and ran it in
unconfined_service_t; the later update to 42.1.18 relabeled the binary to
redis_exec_t, but the process stayed unconfined, and httpd_t may not connect
to its socket there. setup_basic already runs selinux right after
policycoreutils.
…cket

nextcloud__redis_unixsocket and nextcloud__redis_group default to Valkey on
RHEL 10 and Redis elsewhere, as setup_nextcloud installs them. The web server
user joins the group; SELinux allows httpd_t to connect to redis_t and to
write redis_var_run_t sockets without a boolean (selinux-policy 42.1.18 on
Rocky 10).

'redis port' is removed instead of set to 0: RedisFactory picks 6379 for a
host name and the socket for a path when the port is missing, so removing the
port first and changing the host second keeps every step valid. Setting the
host to the socket path first left port 6379 next to it, after which every
occ call failed at bootstrap, including the one meant to fix the port
(reproduced on Rocky 10 with Nextcloud 35.0.0).
…ange

Both PHP-FPM restarts ran on every run, and occ background:cron had no
changed_when. restorecon now runs without --force, which reset the SELinux
user of the files the installer and Nextcloud created since the last run; the
targeted policy ignores the SELinux user. Apps installed during the run get a
restorecon of their own, since the notify_push binary needs bin_t and the
notify_push block that relabels it is skipped with nextcloud__skip_notify_push.
Nextcloud writes config.php at the start of maintenance:install, so a failed
attempt made `creates: config.php` skip the installer on every later run. The
role asks `occ status` instead: it reports installed=false for an empty
config.php (the notice goes to stderr), and the installer may overwrite the
config as long as config/CAN_INSTALL from the tarball exists, which only a
successful installation removes (lib/private/Config.php, verified with
Nextcloud 35.0.0).
… sets

The RHEL package ships `Z /etc/valkey ~0750 valkey root` in tmpfiles.d, which
systemd-tmpfiles applies at every boot and on every RPM tmpfiles trigger, so
the root:valkey the role set flipped back to valkey:root and the next run
reported a change (verified on Rocky 10). Debian and Ubuntu keep root:valkey.
With this, a second run of setup_nextcloud on a fresh Rocky 10 reports
changed=0.
Same as for Valkey: the Remi package ships `Z /etc/redis ~0750 redis root` in
tmpfiles.d (redis 8.8 for EL9).
… list

nextcloud-update passed the credentials to curl with --user. They now reach
curl as a header file read from a pipe that the printf builtin fills
(--header @file since curl 7.55, verified with curl 7.61.1 on Rocky 8).
CONTRIBUTING: software versions must always be mandatory variables. The
variable only picks the tarball of a new installation; updates of an
installed Nextcloud run through nextcloud-update. The Molecule scenario pins
'latest-35'.
The role handed the database administrator to occ maintenance:install, which
then created 'oc_<admin>'@'%' (lib/private/Setup/MySQL.php). setup_nextcloud
now creates the database and nextcloud__database_login@localhost through
mariadb_server, with the charset, collation and privileges the installer
would use, and the installer gets that user. Without the privilege to read
mysql.user, createSpecificUser() falls back to the provided credentials, so
config.php holds nextcloud__database_login and no 'oc_' user is created
(verified with Nextcloud 35.0.0 and MariaDB 11.4 on Rocky 10).
nextcloud__mariadb_login is gone; the version check queries MariaDB as the
new user as well.
The playbook installs Collabora on the Nextcloud host but leaves the reverse
proxy and the richdocuments settings to the inventory. The walkthrough
follows the setup in production: a hostname of its own for Collabora on the
proxy, forwarded to port 9980, and the proxy in wopi_allowlist.
The template is the coolwsd.xml of coolwsd-26.04.4.1-1 with the adaptations
of the 26.04.3 template carried over (experimental features, post_allow and
lok_allow entries, WOPI alias groups and the settings the 26.04.3 patch
already applied). An element-wise comparison against the shipped file shows
exactly these differences. Upstream switched post_allow and lok_allow to
CIDR notation; the entries from collabora__coolwsd_post_allow and
collabora__coolwsd_lok_allow stay regular expressions, which coolwsd still
accepts.
The filter had a stray quote before the second match() and the body a
trailing comma, so the Icinga API answered 400 and borg-backup, which runs
curl with --silent and discards the output, never set the downtime
(verified against Icinga 2.14.6: 400 before, 200 now).
…l users

borg-backup was deployed 0755 and passed the credentials to curl with
--user. It is now 0700, since only root runs it (systemd timers), and the
credentials reach curl as a header read from a pipe that the printf builtin
fills.
…cal users

do-reboot was deployed 0744 and passed the Icinga API credentials with
--user and the Rocket.Chat webhook URL, which carries its token, as curl
arguments. It is now 0700, since only schedule-reboot.service runs it, the
credentials reach curl as a header from a pipe, and the URL through
--config <(printf ...), verified with curl 7.61.1 on Rocky 8. The Molecule
reboot scenario still sees the same Authorization header.
schedule-icinga-downtime was a shell function in /etc/profile.d/alias.sh,
which every login shell reads, so every local user could read the
credentials and set downtimes with them. It is now
/usr/local/sbin/schedule-icinga-downtime, readable and executable by root
only, which the reboot alias calls through root's PATH. The function also
ran `exit 1` on a missing argument, which closed the calling shell. Verified
on Rocky 10: the script sets the downtime for the host and all its services
on Icinga 2.14.6, and strace shows the password in no execve argument.
markuslf and others added 8 commits September 22, 2026 19:19
The role keeps all Nextcloud caches (local, distributed, locking) in Redis
or Valkey, so APCu was installed but never used. Hosts that already have it
keep it: an inventory that sets memcache.local to APCu would break if the
role removed the package. Verified on Rocky 10: without php-pecl-apcu the
role reports no change and the Molecule verify passes.
The module list now names every module the role used to install with the
reason it stays or goes. present: what Nextcloud 35 requires or recommends
(OC_Util::checkServer(), SetupChecks/PhpModules.php), redis, ldap for
user_ldap, smbclient for SMB external storage, opcache and imagick, which
have setup checks of their own. absent: apcu and memcached (all caches live
in Redis / Valkey), bcmath (gmp covers WebAuthn and SFTP) and imap (only the
IMAP backend of user_external). php-json is dropped from the list instead of
set absent, since on RHEL it is only a name that php-common provides.
Verified on Rocky 10 with Remi PHP 8.5: the first run removes the four
packages, the second reports no change, and the Molecule verify passes.
…only scripts

The valkey scenario asserted root:valkey on valkey.conf, which the role no
longer sets on RHEL, where it keeps the valkey:root that the package's
tmpfiles.d rule restores; the assertion now follows the platform and covers
the Unix socket, read back from the running server and 0770 with the valkey
group. The schedule_reboot and setup_nextcloud scenarios assert that
do-reboot and nextcloud-update, which carry the Icinga API credentials, are
readable by root only. Verified on Rocky 10.
CONTRIBUTING: no upstream version numbers in texts that stay in the repo,
they age with the next release. The role checks the requirements itself and
names the versions it expects in the error message, so the README points at
that instead of repeating "10.11+ for Nextcloud 35". The CHANGELOG entries
outside Breaking Changes are one sentence again.
The version check connected through /var/lib/mysql/mysql.sock on every
platform, which does not exist on Debian and Ubuntu
(/run/mysqld/mysqld.sock). Take the path from vars/<os>.yml, as
icingaweb2 does.
…version description

fail2ban moves to the roles that setup_nextcloud leaves off by default.
The Troubleshooting entries use the bold heading of roles/example and
get two blank lines above the section. argument_specs and the task
comment no longer offer 'latest' for nextcloud__version, matching the
README.
… in setup_nextcloud

Stat the socket Nextcloud uses (mode 0770, group redis or valkey) and
check that the config file keeps the <server>:root ownership and 0640
that the package's tmpfiles.d rule restores at every boot. This covers
the redis role on Rocky 8 and 9 and valkey on Rocky 10. Also re-wraps
one comment.
…o root

Set tools__icinga2_api_* so that schedule-icinga-downtime gets deployed.
Assert that it is 0700 root, and that a login shell of nobody sees the
reboot alias but not the password, and cannot run the script.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants