From 349af3e00abaf71c165508482fff113cc2da3b56 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Mon, 5 Oct 2026 16:51:35 +0200 Subject: [PATCH 1/3] fix(roles/apache_httpd): set PidFile so apachectl finds the server on Debian and Ubuntu The rendered apache2.conf set no PidFile, so Apache used the compiled-in /run/apache2.pid, while apachectl reads APACHE_PID_FILE from /etc/apache2/envvars (/run/apache2/apache2.pid). On a fresh host the package-started server wrote the latter, `apachectl graceful` did not find it, tried to start a second server and failed on the bound ports. Reproduced on Ubuntu 26.04 with main. Hosts still running with the old PID file are restarted once instead of reloaded. Debian's own apache2.conf (and DebOps, which keeps it) sets the same PidFile. --- CHANGELOG.md | 1 + roles/apache_httpd/README.md | 2 +- roles/apache_httpd/handlers/main.yml | 2 ++ roles/apache_httpd/tasks/main.yml | 20 +++++++++++++++++++ .../templates/etc/httpd/conf/httpd.conf.j2 | 3 ++- roles/apache_httpd/vars/Debian.yml | 3 +++ roles/apache_httpd/vars/RedHat.yml | 1 + roles/apache_httpd/vars/Ubuntu.yml | 3 +++ 8 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec16223f..079089bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:apache_httpd**: On Debian and Ubuntu, the first run against a fresh host no longer fails reloading Apache, since the role points `PidFile` to where `apachectl` looks; a server started by an earlier version of the role is restarted once. * **playbook:redis, playbook:setup_mastodon**: On Debian and Ubuntu, the playbooks no longer abort with "'redis__skip_repo_redis' is undefined" or "'setup_mastodon__skip_repo_redis' is undefined" unless the skip variable is set in the inventory. * **role:grafana_grizzly, role:monitoring_plugins_grafana_dashboards**: On Grafana 13.1 and newer, dashboards that the roles add or update stay in their folder, instead of landing in the root, where viewers and the graphs embedded in IcingaWeb2 got "403". A run also moves back the dashboards that earlier runs left in the root. * **role:aide**: The AIDE check no longer fails on hosts with fwupd, where it reported `/etc/fwupd/fwupd.conf` as changed after the first start of the fwupd daemon. diff --git a/roles/apache_httpd/README.md b/roles/apache_httpd/README.md index 81c0f0785..639fa15bd 100644 --- a/roles/apache_httpd/README.md +++ b/roles/apache_httpd/README.md @@ -78,7 +78,7 @@ Manual steps: * Creates or updates the global Apache configuration (`httpd.conf`). * Removes rpmnew/rpmsave files (and Debian equivalents). * Removes, creates, disables, and enables conf-available configs. -* Triggers: httpd.service reload. +* Triggers: httpd.service reload, or a restart on Debian and Ubuntu where Apache still runs with the PID file of an earlier version of the role (`/run/apache2.pid`). `apache_httpd:htpasswd` diff --git a/roles/apache_httpd/handlers/main.yml b/roles/apache_httpd/handlers/main.yml index ab9189db5..cf1f7b872 100644 --- a/roles/apache_httpd/handlers/main.yml +++ b/roles/apache_httpd/handlers/main.yml @@ -16,6 +16,8 @@ state: 'reloaded' when: - '__apache_httpd__service_state_result is not defined or __apache_httpd__service_state_result is not changed' + # a server with the PID file of an earlier role version cannot be reloaded, it gets restarted (see tasks) + - 'not (__apache_httpd__legacy_pid_file_result["stat"]["exists"] | d(false))' - 'apache_httpd__systemd_state != "stopped"' - name: 'apache_httpd: httpd -t; systemctl restart httpd' diff --git a/roles/apache_httpd/tasks/main.yml b/roles/apache_httpd/tasks/main.yml index b3d190a58..03b83dcd8 100644 --- a/roles/apache_httpd/tasks/main.yml +++ b/roles/apache_httpd/tasks/main.yml @@ -77,6 +77,17 @@ # Manage global Apache config - block: + # Before the role set PidFile, Apache on Debian and Ubuntu wrote its PID to the compiled-in + # default /run/apache2.pid, where apachectl no longer looks. `apachectl graceful` then takes + # the server for stopped, tries to start a second one and fails on the bound ports, so such a + # server has to be restarted once instead of reloaded. + - name: 'stat /run/apache2.pid' + ansible.builtin.stat: + path: '/run/apache2.pid' + register: '__apache_httpd__legacy_pid_file_result' + when: + - 'ansible_facts["os_family"] == "Debian"' + - name: 'Create or update global Apache configuration {{ __apache_httpd__config_file }}' ansible.builtin.template: backup: true @@ -88,6 +99,15 @@ notify: - 'apache_httpd: httpd -t; systemctl reload httpd' + - name: 'Request a restart of {{ __apache_httpd__service_name }}, which runs with the PID file of an earlier role version' + ansible.builtin.debug: + msg: '/run/apache2.pid exists, so {{ __apache_httpd__service_name }} is restarted instead of reloaded' + changed_when: true # notify the restart handler; the reload handler skips this case + notify: + - 'apache_httpd: httpd -t; systemctl restart httpd' + when: + - '__apache_httpd__legacy_pid_file_result["stat"]["exists"] | d(false)' + - name: 'Remove rpmnew / rpmsave (and Debian equivalents)' ansible.builtin.include_role: name: 'shared' diff --git a/roles/apache_httpd/templates/etc/httpd/conf/httpd.conf.j2 b/roles/apache_httpd/templates/etc/httpd/conf/httpd.conf.j2 index 0dd247e77..71f6f48cb 100644 --- a/roles/apache_httpd/templates/etc/httpd/conf/httpd.conf.j2 +++ b/roles/apache_httpd/templates/etc/httpd/conf/httpd.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026090301 +# 2026100501 # This is the main Apache server configuration file. It contains the # configuration directives that give the server its instructions. @@ -74,6 +74,7 @@ LimitRequestFieldSize {{ apache_httpd__conf_limit_request_field_size }} LimitRequestLine {{ apache_httpd__conf_limit_request_line }} LogLevel {{ apache_httpd__conf_log_level }} MaxKeepAliveRequests {{ apache_httpd__conf_max_keep_alive_requests }} +PidFile {{ __apache_httpd__pid_file }} ServerAdmin {{ apache_httpd__conf_server_admin }} {% if apache_httpd__conf_server_name %} ServerName {{ apache_httpd__conf_server_name }} diff --git a/roles/apache_httpd/vars/Debian.yml b/roles/apache_httpd/vars/Debian.yml index 9970eb357..961da5e1d 100644 --- a/roles/apache_httpd/vars/Debian.yml +++ b/roles/apache_httpd/vars/Debian.yml @@ -14,6 +14,9 @@ __apache_httpd__openssl_privatekey_path: '/etc/pki/tls/private' # state: 'present' __apache_httpd__php_set_handler_default: 'SetHandler "proxy:unix:{{ __apache_httpd__php_socket }}|fcgi://localhost"' __apache_httpd__php_socket: '/run/php/www.sock' +# from /etc/apache2/envvars, which apachectl reads; the compiled-in default /run/apache2.pid differs, +# so apachectl would not find a server that the package started with its own apache2.conf +__apache_httpd__pid_file: '${APACHE_PID_FILE}' __apache_httpd__root_path: '/var/www' __apache_httpd__roothtml_path: '/var/www/html' __apache_httpd__service_name: 'apache2' diff --git a/roles/apache_httpd/vars/RedHat.yml b/roles/apache_httpd/vars/RedHat.yml index 3822fbdb0..7ff1c29a5 100644 --- a/roles/apache_httpd/vars/RedHat.yml +++ b/roles/apache_httpd/vars/RedHat.yml @@ -12,6 +12,7 @@ __apache_httpd__openssl_chain_path: '/etc/pki/tls/certs' __apache_httpd__openssl_privatekey_path: '/etc/pki/tls/private' __apache_httpd__php_set_handler_default: 'SetHandler "proxy:unix:{{ __apache_httpd__php_socket }}|fcgi://localhost"' __apache_httpd__php_socket: '/run/php-fpm/www.sock' +__apache_httpd__pid_file: 'run/httpd.pid' # the compiled-in default __apache_httpd__root_path: '/var/www' __apache_httpd__roothtml_path: '/var/www/html' __apache_httpd__service_name: 'httpd' diff --git a/roles/apache_httpd/vars/Ubuntu.yml b/roles/apache_httpd/vars/Ubuntu.yml index 9970eb357..961da5e1d 100644 --- a/roles/apache_httpd/vars/Ubuntu.yml +++ b/roles/apache_httpd/vars/Ubuntu.yml @@ -14,6 +14,9 @@ __apache_httpd__openssl_privatekey_path: '/etc/pki/tls/private' # state: 'present' __apache_httpd__php_set_handler_default: 'SetHandler "proxy:unix:{{ __apache_httpd__php_socket }}|fcgi://localhost"' __apache_httpd__php_socket: '/run/php/www.sock' +# from /etc/apache2/envvars, which apachectl reads; the compiled-in default /run/apache2.pid differs, +# so apachectl would not find a server that the package started with its own apache2.conf +__apache_httpd__pid_file: '${APACHE_PID_FILE}' __apache_httpd__root_path: '/var/www' __apache_httpd__roothtml_path: '/var/www/html' __apache_httpd__service_name: 'apache2' From 53e08195c8b43bc9056a2142bc8ac05c65d5badc Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Mon, 5 Oct 2026 16:51:44 +0200 Subject: [PATCH 2/3] fix(roles/apache_httpd): deploy the OWASP CRS on Debian and Ubuntu - create modsecurity.d, which only the RHEL package ships - assert ModSecurity >= 2.9.6 (CRS 4 uses MULTIPART_PART_HEADERS; Ubuntu 22.04 ships 2.9.5, verified) - download the CRS on the controller instead of the target - reload Apache when the CRS symlink or crs-setup.conf change - extract with fixed modes, otherwise `chmod -R g-w` made every run re-extract the archive - molecule: run the scenario behind the CRS and assert a 403 from the configured release on all platforms except Ubuntu 22.04 --- CHANGELOG.md | 3 + COMPATIBILITY.md | 2 +- .../group_vars/systems_under_test.yml | 23 +++++++ .../inventory/host_vars/ubuntu2204-vm.yml | 6 ++ extensions/molecule/apache_httpd/verify.yml | 49 ++++++++++++++- roles/apache_httpd/README.md | 14 ++++- roles/apache_httpd/tasks/main.yml | 2 +- .../tasks/mod_security_coreruleset.yml | 60 +++++++++++++++++-- roles/apache_httpd/vars/Debian.yml | 1 + roles/apache_httpd/vars/RedHat.yml | 1 + roles/apache_httpd/vars/Ubuntu.yml | 1 + 11 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 extensions/molecule/apache_httpd/inventory/host_vars/ubuntu2204-vm.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 079089bb9..184b2e2c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed * Downloads from the Internet, such as release tarballs, GPG keys, git clones, GitHub release lookups and package installations, are retried up to three times, so a brief outage of a download source or package repository no longer aborts the run (all roles). +* **role:apache_httpd**: The OWASP Core Rule Set is downloaded on the Ansible controller, so the target no longer needs access to GitHub. * **role:monitoring_plugins**: On Linux, the role no longer stops the Icinga2 agent while it deploys the plugins, so a run no longer interrupts the monitoring of the host. * **role:icinga2_agent**: The role runs `icinga2 node setup` and restarts the agent only when the agent needs a new certificate or its settings changed, instead of on every run. * LFOps requires community.general 7.0.0 or newer (still below 9.0.0), which `ansible-galaxy collection install linuxfabrik.lfops` pulls in, while a manually maintained collection list has to be raised. @@ -92,6 +93,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * **role:apache_httpd**: On Debian and Ubuntu, the first run against a fresh host no longer fails reloading Apache, since the role points `PidFile` to where `apachectl` looks; a server started by an earlier version of the role is restarted once. +* **role:apache_httpd**: Apache reloads after the OWASP Core Rule Set is updated, instead of applying the previous rules until its next reload. +* **role:apache_httpd**: The OWASP Core Rule Set deploys on Debian and Ubuntu, where the run failed until now, and on Ubuntu 22.04, whose ModSecurity 2.9.5 is too old for it, the run aborts with a message saying so. * **playbook:redis, playbook:setup_mastodon**: On Debian and Ubuntu, the playbooks no longer abort with "'redis__skip_repo_redis' is undefined" or "'setup_mastodon__skip_repo_redis' is undefined" unless the skip variable is set in the inventory. * **role:grafana_grizzly, role:monitoring_plugins_grafana_dashboards**: On Grafana 13.1 and newer, dashboards that the roles add or update stay in their folder, instead of landing in the root, where viewers and the graphs embedded in IcingaWeb2 got "403". A run also moves back the dashboards that earlier runs left in the root. * **role:aide**: The AIDE check no longer fails on hosts with fwupd, where it reported `/etc/fwupd/fwupd.conf` as changed after the first start of the fwupd daemon. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 8e3ddf745..e070ad864 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -12,7 +12,7 @@ Which Ansible role is proven to run on which OS? | aide | x | x | x | x | x | x | x | x | | | alternatives | x | x | x | x | (x) | x | x | (x) | | | ansible_init | | | | | | | | | Fedora 35+ | -| apache_httpd | x | x | x | x | x | (x) | x | (x) | | +| apache_httpd | x | x | x | x | x | x | x | x | | | apache_solr | | | x | x | (x) | | | | | | apache_tomcat | | | x | x | (x) | | | | | | apps | (x) | (x) | x | x | x | (x) | (x) | (x) | | diff --git a/extensions/molecule/apache_httpd/inventory/group_vars/systems_under_test.yml b/extensions/molecule/apache_httpd/inventory/group_vars/systems_under_test.yml index fc6f0b1c7..296411493 100644 --- a/extensions/molecule/apache_httpd/inventory/group_vars/systems_under_test.yml +++ b/extensions/molecule/apache_httpd/inventory/group_vars/systems_under_test.yml @@ -1,5 +1,23 @@ apache_httpd__conf_server_admin: 'webmaster@example.com' +# The OWASP Core Rule Set, included server-wide so that every vHost below runs behind it. verify.yml +# sends an attack and expects the rules to block it, which proves that the release the role deployed +# is the one the running Apache applies. The ordinary requests of the other checks pass through the +# same rules. Ubuntu 22.04 ships a ModSecurity that is too old for the CRS (see host_vars). +apache_httpd__conf__group_var: + - filename: 'z00-crs' + enabled: true + state: 'present' + template: 'raw' + raw: |- + SecRuleEngine On + SecRequestBodyAccess On + Include modsecurity.d/crs/crs-setup.conf + Include modsecurity.d/crs/rules/*.conf +# Pinned here as well, because verify.yml compares the release in the log against it. +apache_httpd__mod_security_coreruleset_version: '4.27.0' +apache_httpd__skip_mod_security_coreruleset: false + # The same user in two htpasswd files. The username and the path together identify an entry, so # both entries have to reach their own file instead of being merged into one. Each file protects # the location of h2.example.com named after it (see the raw block below), and verify.yml logs in @@ -16,10 +34,15 @@ apache_httpd__htpasswd__group_var: password: 'linuxfabrik' path: '/var/www/htpasswd-b' +# mod_security2 is off by default and needed for the Core Rule Set above. # The role enables the deflate conf, but leaves mod_deflate and mod_filter off, and the deflate conf # sits inside blocks for both. Without them nothing is compressed, so the compression # check in verify.yml needs them loaded, the way a reverse proxy that compresses has them. apache_httpd__mods__group_var: + - filename: 'security2' + enabled: true + state: 'present' + template: 'security2' - filename: 'deflate' enabled: true state: 'present' diff --git a/extensions/molecule/apache_httpd/inventory/host_vars/ubuntu2204-vm.yml b/extensions/molecule/apache_httpd/inventory/host_vars/ubuntu2204-vm.yml new file mode 100644 index 000000000..54cffb212 --- /dev/null +++ b/extensions/molecule/apache_httpd/inventory/host_vars/ubuntu2204-vm.yml @@ -0,0 +1,6 @@ +# Ubuntu 22.04 ships ModSecurity 2.9.5, which rejects the rules of the OWASP Core Rule Set 4, so the +# role refuses to deploy them there. The host runs the rest of the scenario without them. +apache_httpd__conf__host_var: + - filename: 'z00-crs' + state: 'absent' +apache_httpd__skip_mod_security_coreruleset: true diff --git a/extensions/molecule/apache_httpd/verify.yml b/extensions/molecule/apache_httpd/verify.yml index 4e431ba8e..4bf87c59f 100644 --- a/extensions/molecule/apache_httpd/verify.yml +++ b/extensions/molecule/apache_httpd/verify.yml @@ -9,7 +9,8 @@ # # The htpasswd files are checked the same way: by logging in through Apache, not by reading them. # So is the compression: by the Content-Encoding of a real response, not by the deflate conf. -- name: 'Verify apache_httpd serves HTTP/2, basic auth and compression where it is configured to' +# And the OWASP Core Rule Set: by an attack that the running Apache blocks, not by the rule files. +- name: 'Verify apache_httpd serves HTTP/2, basic auth, compression and the Core Rule Set where it is configured to' hosts: 'systems_under_test' gather_facts: true become: true @@ -237,3 +238,49 @@ - '__molecule__apache_httpd_curl_deflate_result["stdout"] is search("^content-type: text/markdown", ignorecase=true, multiline=true)' - '__molecule__apache_httpd_curl_deflate_result["stdout"] is search("^content-encoding: gzip", ignorecase=true, multiline=true)' fail_msg: 'expected a gzip-compressed text/markdown response, got: {{ __molecule__apache_httpd_curl_deflate_result["stdout"] }}' + + # The Core Rule Set is included server-wide (see group_vars), so the first vHost has to block a + # request carrying a shell command in a parameter (rule 932160, paranoia level 1). The 200 for + # every vHost above shows that ordinary requests still pass. A host where the rules are not + # loaded answers 200 here, and one that runs an older release than the inventory asks for logs + # a different version in the rule match, which the second assertion catches. + - name: 'curl https:///?exec=/bin/bash' # noqa command-instead-of-module (ansible.builtin.uri cannot pin the name to 127.0.0.1 the way --resolve does) + ansible.builtin.command: >- + curl --silent --insecure + --resolve {{ __molecule__server_name }}:443:127.0.0.1 + --output /dev/null + --write-out '%{http_code}' + https://{{ __molecule__server_name }}/?exec=/bin/bash + register: '__molecule__apache_httpd_curl_crs_result' + changed_when: false + vars: + __molecule__server_name: '{{ apache_httpd__vhosts__group_var[0]["conf_server_name"] }}' + when: + - 'not apache_httpd__skip_mod_security_coreruleset | bool' + + - name: 'grep the release of the matching rule from -error.log' + ansible.builtin.command: >- + grep --only-matching --max-count 1 --perl-regexp + '\[id "932160"\].*\[ver "\KOWASP_CRS/[^"]+' + {{ __molecule__error_log }} + register: '__molecule__apache_httpd_crs_version_result' + changed_when: false + failed_when: false + vars: + __molecule__error_log: >- + {{ (ansible_facts["os_family"] == "Debian") | ternary("/var/log/apache2", "/var/log/httpd") }}/{{ + apache_httpd__vhosts__group_var[0]["conf_server_name"] }}-error.log + when: + - 'not apache_httpd__skip_mod_security_coreruleset | bool' + + - name: 'Assert the Core Rule Set blocks the attack with the configured release' + ansible.builtin.assert: + that: + - '__molecule__apache_httpd_curl_crs_result["stdout"] == "403"' + - '__molecule__apache_httpd_crs_version_result["stdout"] == "OWASP_CRS/" ~ apache_httpd__mod_security_coreruleset_version' + fail_msg: >- + expected 403 from OWASP_CRS/{{ apache_httpd__mod_security_coreruleset_version }}, got + {{ __molecule__apache_httpd_curl_crs_result["stdout"] }} and + "{{ __molecule__apache_httpd_crs_version_result["stdout"] }}" in the error log + when: + - 'not apache_httpd__skip_mod_security_coreruleset | bool' diff --git a/roles/apache_httpd/README.md b/roles/apache_httpd/README.md index 639fa15bd..6fda9ebe6 100644 --- a/roles/apache_httpd/README.md +++ b/roles/apache_httpd/README.md @@ -41,11 +41,18 @@ This role supports both Red Hat and Debian-based systems. The following paths an * User/Group: Red Hat `apache`/`apache`, Debian `www-data`/`www-data` * PHP-FPM socket: Red Hat `/run/php-fpm/www.sock`, Debian `/run/php/www.sock` +The OWASP ModSecurity Core Rule Set (CRS) is downloaded on the Ansible controller and copied to the target, so only the controller needs access to GitHub. The role deploys the rules to `/etc/httpd/modsecurity.d/crs/`, but does not activate them: include `modsecurity.d/crs/crs-setup.conf` and `modsecurity.d/crs/rules/*.conf` in a vHost (see [EXAMPLES.md](https://github.com/Linuxfabrik/lfops/blob/main/roles/apache_httpd/EXAMPLES.md)). Each version is kept in its own directory, so a change of `apache_httpd__mod_security_coreruleset_version` switches the symlink and reloads Apache, and setting the previous version again rolls back. `crs-setup.conf` is reset to the shipped example on every run; put your CRS settings into the vHost. + This role does NOT: * install PHP or PHP-FPM. It prefers PHP-FPM over mod_php, but installs neither. +## Known Limitations + +* The OWASP Core Rule Set needs ModSecurity 2.9.6 or newer. Ubuntu 22.04 ships 2.9.5, so `apache_httpd__skip_mod_security_coreruleset` has to stay `true` there. + + ## Dependent Roles Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/README.md) that installs this role runs these for you. Optional ones can be disabled via the playbook's skip variables. @@ -87,11 +94,12 @@ Manual steps: `apache_httpd:mod_security_coreruleset` +* Aborts if the installed ModSecurity is older than 2.9.6, which the Core Rule Set 4 needs. * Installs `tar`. -* Downloads and verifies the OWASP ModSecurity Core Rule Set (CRS). -* Extracts the archive and creates a symlink to the CRS directory. +* Downloads the OWASP ModSecurity Core Rule Set (CRS) on the Ansible controller. +* Extracts the archive to `/etc/httpd/modsecurity.d/` and points the `modsecurity.d/crs` symlink at it. * Copies the default `crs-setup.conf.example` to `crs-setup.conf`. -* Triggers: none. +* Triggers: httpd.service reload. `apache_httpd:mods` diff --git a/roles/apache_httpd/tasks/main.yml b/roles/apache_httpd/tasks/main.yml index 03b83dcd8..9450c74bd 100644 --- a/roles/apache_httpd/tasks/main.yml +++ b/roles/apache_httpd/tasks/main.yml @@ -67,7 +67,7 @@ - ansible.builtin.include_tasks: 'mod_security_coreruleset.yml' when: - - 'not apache_httpd__skip_mod_security_coreruleset' + - 'not apache_httpd__skip_mod_security_coreruleset | bool' tags: - 'apache_httpd' diff --git a/roles/apache_httpd/tasks/mod_security_coreruleset.yml b/roles/apache_httpd/tasks/mod_security_coreruleset.yml index cf568fde0..b3bc0d685 100644 --- a/roles/apache_httpd/tasks/mod_security_coreruleset.yml +++ b/roles/apache_httpd/tasks/mod_security_coreruleset.yml @@ -1,10 +1,39 @@ # Manage OWASP ModSecurity Core Rule Set (CRS), https://coreruleset.org/docs/deployment/install/ -- name: 'Download, verify and install OWASP ModSecurity Core Rule Set (CRS)' + +- name: 'Get the list of installed packages' + ansible.builtin.package_facts: # yamllint disable-line rule:empty-values + check_mode: false # run task even if `--check` is specified + +# CRS 4 references the MULTIPART_PART_HEADERS collection, which ModSecurity 2.9.6 introduced. +# ModSecurity 2.9.5 rejects the rule files with "Unknown variable: &MULTIPART_PART_HEADERS", so +# Apache would refuse to start or reload once the rules are included. Verified with CRS 4.27.0 and +# 4.30.0 against mod_security 2.9.5-1 on Ubuntu 22.04 (fails) and 2.9.6 on Rocky 8 and 9 (works). +# A missing package is left to the package tasks of the role, which run before this file. +- name: 'Assert that the installed ModSecurity supports the Core Rule Set' + ansible.builtin.assert: + that: + - '__apache_httpd__mod_security_package not in ansible_facts["packages"] + or ansible_facts["packages"][__apache_httpd__mod_security_package][0]["version"] is version("2.9.6", ">=")' + quiet: true + fail_msg: >- + {{ __apache_httpd__mod_security_package }} + {{ ansible_facts["packages"][__apache_httpd__mod_security_package][0]["version"] | d("") }} + is too old for the OWASP Core Rule Set 4, which needs ModSecurity 2.9.6 or newer. + Set `apache_httpd__skip_mod_security_coreruleset: true` on this host. + +- name: 'curl --output /tmp/ansible.coreruleset-v{{ apache_httpd__mod_security_coreruleset_version }}.tar.gz {{ apache_httpd__mod_security_coreruleset_url }}/v{{ apache_httpd__mod_security_coreruleset_version }}.tar.gz' # noqa risky-file-permissions (temporary file) ansible.builtin.get_url: url: '{{ apache_httpd__mod_security_coreruleset_url }}/v{{ apache_httpd__mod_security_coreruleset_version }}.tar.gz' - dest: '/tmp/coreruleset-v{{ apache_httpd__mod_security_coreruleset_version }}.tar.gz' + dest: '/tmp/ansible.coreruleset-v{{ apache_httpd__mod_security_coreruleset_version }}.tar.gz' + mode: 0o644 retries: 3 # ride out a brief outage of the download source delay: 10 + delegate_to: 'localhost' + become: false + vars: + ansible_become: false # noqa var-naming[pattern] + changed_when: false # not an actual config change on the target + check_mode: false # run task even if `--check` is specified - name: 'install tar' ansible.builtin.package: @@ -13,11 +42,24 @@ retries: 3 # ride out a brief outage of the package repository delay: 10 -- name: 'tar xzf coreruleset-v{{ apache_httpd__mod_security_coreruleset_version }}.tar.gz' +# The mod_security package creates this directory on the Red Hat family, the Debian package does not. +- name: 'mkdir -p {{ __apache_httpd__config_path }}/modsecurity.d' + ansible.builtin.file: + path: '{{ __apache_httpd__config_path }}/modsecurity.d' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + +- name: 'tar xzf /tmp/ansible.coreruleset-v{{ apache_httpd__mod_security_coreruleset_version }}.tar.gz -C {{ __apache_httpd__config_path }}/modsecurity.d/' ansible.builtin.unarchive: - src: '/tmp/coreruleset-v{{ apache_httpd__mod_security_coreruleset_version }}.tar.gz' + src: '/tmp/ansible.coreruleset-v{{ apache_httpd__mod_security_coreruleset_version }}.tar.gz' dest: '{{ __apache_httpd__config_path }}/modsecurity.d/' - remote_src: true + owner: 'root' + group: 'root' + # the archive carries group-writable files, which the `chmod -R g-w` of the config directory + # further down would revert, so the archive would be extracted again on every run + mode: 'u=rwX,g=rX,o=rX' ignore_errors: '{{ ansible_check_mode }}' # ignore errors if `--check` is specified - name: 'ln -s {{ __apache_httpd__config_path }}/modsecurity.d/coreruleset-{{ apache_httpd__mod_security_coreruleset_version }} {{ __apache_httpd__config_path }}/modsecurity.d/crs' @@ -26,10 +68,16 @@ dest: '{{ __apache_httpd__config_path }}/modsecurity.d/crs' state: 'link' ignore_errors: '{{ ansible_check_mode }}' # ignore errors if `--check` is specified + # Apache reads the rules at startup, so it keeps applying the previous release until it is reloaded + notify: 'apache_httpd: httpd -t; systemctl reload httpd' -- name: 'cp {{ __apache_httpd__config_path }}/modsecurity.d/crs-setup.conf.example {{ __apache_httpd__config_path }}/modsecurity.d/crs-setup.conf' +- name: 'cp {{ __apache_httpd__config_path }}/modsecurity.d/crs/crs-setup.conf.example {{ __apache_httpd__config_path }}/modsecurity.d/crs/crs-setup.conf' ansible.builtin.copy: src: '{{ __apache_httpd__config_path }}/modsecurity.d/crs/crs-setup.conf.example' dest: '{{ __apache_httpd__config_path }}/modsecurity.d/crs/crs-setup.conf' remote_src: true + owner: 'root' + group: 'root' + mode: 0o644 ignore_errors: '{{ ansible_check_mode }}' # ignore errors if `--check` is specified + notify: 'apache_httpd: httpd -t; systemctl reload httpd' diff --git a/roles/apache_httpd/vars/Debian.yml b/roles/apache_httpd/vars/Debian.yml index 961da5e1d..5bf855bdd 100644 --- a/roles/apache_httpd/vars/Debian.yml +++ b/roles/apache_httpd/vars/Debian.yml @@ -6,6 +6,7 @@ __apache_httpd__config_path: '/etc/apache2' __apache_httpd__config_prefix: 'lfops-' # deploy our configs into an directory that is not influenced by the package manager __apache_httpd__config_test_command: 'apachectl -t' __apache_httpd__log_path: '/var/log/apache2' +__apache_httpd__mod_security_package: 'libapache2-mod-security2' __apache_httpd__modules_path: '/usr/lib/apache2/modules' __apache_httpd__openssl_certificate_path: '/etc/pki/tls/certs' __apache_httpd__openssl_chain_path: '/etc/pki/tls/certs' diff --git a/roles/apache_httpd/vars/RedHat.yml b/roles/apache_httpd/vars/RedHat.yml index 7ff1c29a5..780119540 100644 --- a/roles/apache_httpd/vars/RedHat.yml +++ b/roles/apache_httpd/vars/RedHat.yml @@ -6,6 +6,7 @@ __apache_httpd__config_path: '/etc/httpd' __apache_httpd__config_prefix: '' __apache_httpd__config_test_command: 'httpd -t' __apache_httpd__log_path: '/var/log/httpd' +__apache_httpd__mod_security_package: 'mod_security' __apache_httpd__modules_path: 'modules' __apache_httpd__openssl_certificate_path: '/etc/pki/tls/certs' __apache_httpd__openssl_chain_path: '/etc/pki/tls/certs' diff --git a/roles/apache_httpd/vars/Ubuntu.yml b/roles/apache_httpd/vars/Ubuntu.yml index 961da5e1d..5bf855bdd 100644 --- a/roles/apache_httpd/vars/Ubuntu.yml +++ b/roles/apache_httpd/vars/Ubuntu.yml @@ -6,6 +6,7 @@ __apache_httpd__config_path: '/etc/apache2' __apache_httpd__config_prefix: 'lfops-' # deploy our configs into an directory that is not influenced by the package manager __apache_httpd__config_test_command: 'apachectl -t' __apache_httpd__log_path: '/var/log/apache2' +__apache_httpd__mod_security_package: 'libapache2-mod-security2' __apache_httpd__modules_path: '/usr/lib/apache2/modules' __apache_httpd__openssl_certificate_path: '/etc/pki/tls/certs' __apache_httpd__openssl_chain_path: '/etc/pki/tls/certs' From 1e4408699c1b5b8a9d0ad32ae75546dbca32c2a2 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Mon, 5 Oct 2026 16:51:46 +0200 Subject: [PATCH 3/3] chore(roles/apache_httpd): bump OWASP CRS to 4.30.0 --- CHANGELOG.md | 1 + .../apache_httpd/inventory/group_vars/systems_under_test.yml | 2 +- roles/apache_httpd/README.md | 4 ++-- roles/apache_httpd/defaults/main.yml | 2 +- roles/apache_httpd/meta/argument_specs.yml | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 184b2e2c6..4867d7060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed * Downloads from the Internet, such as release tarballs, GPG keys, git clones, GitHub release lookups and package installations, are retried up to three times, so a brief outage of a download source or package repository no longer aborts the run (all roles). +* **role:apache_httpd**: Bump the OWASP Core Rule Set to 4.30.0. * **role:apache_httpd**: The OWASP Core Rule Set is downloaded on the Ansible controller, so the target no longer needs access to GitHub. * **role:monitoring_plugins**: On Linux, the role no longer stops the Icinga2 agent while it deploys the plugins, so a run no longer interrupts the monitoring of the host. * **role:icinga2_agent**: The role runs `icinga2 node setup` and restarts the agent only when the agent needs a new certificate or its settings changed, instead of on every run. diff --git a/extensions/molecule/apache_httpd/inventory/group_vars/systems_under_test.yml b/extensions/molecule/apache_httpd/inventory/group_vars/systems_under_test.yml index 296411493..85e22b94b 100644 --- a/extensions/molecule/apache_httpd/inventory/group_vars/systems_under_test.yml +++ b/extensions/molecule/apache_httpd/inventory/group_vars/systems_under_test.yml @@ -15,7 +15,7 @@ apache_httpd__conf__group_var: Include modsecurity.d/crs/crs-setup.conf Include modsecurity.d/crs/rules/*.conf # Pinned here as well, because verify.yml compares the release in the log against it. -apache_httpd__mod_security_coreruleset_version: '4.27.0' +apache_httpd__mod_security_coreruleset_version: '4.30.0' apache_httpd__skip_mod_security_coreruleset: false # The same user in two htpasswd files. The username and the path together identify an entry, so diff --git a/roles/apache_httpd/README.md b/roles/apache_httpd/README.md index 6fda9ebe6..8182dd6c3 100644 --- a/roles/apache_httpd/README.md +++ b/roles/apache_httpd/README.md @@ -889,7 +889,7 @@ apache_httpd__mod_log_config_custom_log: 'logs/access.log combined' * The OWASP ModSecurity Core Rule Set (CRS) version number without "v". * Type: String. -* Default: `'4.27.0'` +* Default: `'4.30.0'` `apache_httpd__skip_mod_security_coreruleset` @@ -901,7 +901,7 @@ Example: ```yaml # optional - mod_security apache_httpd__mod_security_coreruleset_url: 'https://github.com/coreruleset/coreruleset/archive' -apache_httpd__mod_security_coreruleset_version: '4.27.0' +apache_httpd__mod_security_coreruleset_version: '4.30.0' apache_httpd__skip_mod_security_coreruleset: true ``` diff --git a/roles/apache_httpd/defaults/main.yml b/roles/apache_httpd/defaults/main.yml index 5c762bef7..324a3ffe9 100644 --- a/roles/apache_httpd/defaults/main.yml +++ b/roles/apache_httpd/defaults/main.yml @@ -213,7 +213,7 @@ apache_httpd__mod_http2_window_size: 65535 # get details from https://coreruleset.org/docs/deployment/install/ # origin: https://github.com/coreruleset/coreruleset/releases apache_httpd__mod_security_coreruleset_url: 'https://github.com/coreruleset/coreruleset/archive' -apache_httpd__mod_security_coreruleset_version: '4.27.0' +apache_httpd__mod_security_coreruleset_version: '4.30.0' # mod_ssl apache_httpd__mod_ssl_ssl_use_stapling: 'off' diff --git a/roles/apache_httpd/meta/argument_specs.yml b/roles/apache_httpd/meta/argument_specs.yml index f24ed35b8..3b041ba47 100644 --- a/roles/apache_httpd/meta/argument_specs.yml +++ b/roles/apache_httpd/meta/argument_specs.yml @@ -279,7 +279,7 @@ argument_specs: apache_httpd__mod_security_coreruleset_version: type: 'str' required: false - default: '4.27.0' + default: '4.30.0' description: >- Version of the OWASP Core Rule Set to deploy.