diff --git a/roles/wordpress-setup/defaults/main.yml b/roles/wordpress-setup/defaults/main.yml index 6ce82c622..3b9f6817c 100644 --- a/roles/wordpress-setup/defaults/main.yml +++ b/roles/wordpress-setup/defaults/main.yml @@ -74,3 +74,14 @@ php_fpm_pm_start_servers: 1 php_fpm_pm_min_spare_servers: 1 php_fpm_pm_max_spare_servers: 3 php_fpm_pm_max_requests: 500 + +# Optional hardening mode: run php-fpm as a non-deploy user and grant writes only to writable paths. +# Add extra writable paths as needed (for example current/web/app/cache). +wordpress_runtime_hardened: false +wordpress_runtime_user: www-data +wordpress_runtime_group: www-data +wordpress_runtime_writable_paths: + - shared/uploads + +# Optional hardening refinement: run WP cron as runtime user when hardening is enabled. +wordpress_runtime_cron_as_runtime_user: false diff --git a/roles/wordpress-setup/tasks/main.yml b/roles/wordpress-setup/tasks/main.yml index 890d7078a..d0d09bf2c 100644 --- a/roles/wordpress-setup/tasks/main.yml +++ b/roles/wordpress-setup/tasks/main.yml @@ -25,6 +25,25 @@ loop_control: label: "{{ item.key }}" +- name: Ensure configured WordPress runtime group exists + getent: + database: group + key: "{{ wordpress_runtime_group }}" + when: wordpress_runtime_hardened + +- name: Ensure configured WordPress runtime user exists + getent: + database: passwd + key: "{{ wordpress_runtime_user }}" + when: wordpress_runtime_hardened + +- name: Ensure hardened writable paths exist and are owned by runtime user + include_tasks: runtime-writable-paths.yml + loop: "{{ wordpress_sites | dict2items }}" + loop_control: + label: "{{ item.key }}" + when: wordpress_runtime_hardened + - name: Create WordPress php-fpm configuration file template: src: php-fpm-pool-wordpress.conf.j2 @@ -49,7 +68,7 @@ cron: name: "{{ item.key }} WordPress cron" minute: "{{ item.value.cron_interval | default('*/15') }}" - user: "{{ web_user }}" + user: "{{ (wordpress_runtime_hardened and wordpress_runtime_cron_as_runtime_user) | ternary(wordpress_runtime_user, web_user) }}" job: "cd {{ www_root }}/{{ item.key }}/{{ item.value.current_path | default('current') }} && wp cron event run --due-now > /dev/null 2>&1" cron_file: "wordpress-{{ item.key | replace('.', '_') }}" state: "{{ (cron_enabled and not item.value.multisite.enabled) | ternary('present', 'absent') }}" @@ -61,7 +80,7 @@ cron: name: "{{ item.key }} WordPress network cron" minute: "{{ item.value.cron_interval_multisite | default('*/30') }}" - user: "{{ web_user }}" + user: "{{ (wordpress_runtime_hardened and wordpress_runtime_cron_as_runtime_user) | ternary(wordpress_runtime_user, web_user) }}" job: "cd {{ www_root }}/{{ item.key }}/{{ item.value.current_path | default('current') }} && (wp site list --field=url | xargs -n1 -I \\% wp --url=\\% cron event run --due-now) > /dev/null 2>&1" cron_file: "wordpress-multisite-{{ item.key | replace('.', '_') }}" state: "{{ (cron_enabled and item.value.multisite.enabled) | ternary('present', 'absent') }}" diff --git a/roles/wordpress-setup/tasks/runtime-writable-paths.yml b/roles/wordpress-setup/tasks/runtime-writable-paths.yml new file mode 100644 index 000000000..46079ee6d --- /dev/null +++ b/roles/wordpress-setup/tasks/runtime-writable-paths.yml @@ -0,0 +1,13 @@ +--- +- name: Ensure hardened writable paths exist and are owned by runtime user + # Keep ownership non-recursive so existing release/shared contents are not mass-chowned. + file: + path: "{{ www_root }}/{{ item.key }}/{{ path }}" + owner: "{{ wordpress_runtime_user }}" + group: "{{ wordpress_runtime_group }}" + mode: '0775' + state: directory + loop: "{{ item.value.runtime_writable_paths | default(wordpress_runtime_writable_paths) }}" + loop_control: + loop_var: path + label: "{{ item.key }} -> {{ path }}" diff --git a/roles/wordpress-setup/templates/php-fpm-pool-wordpress.conf.j2 b/roles/wordpress-setup/templates/php-fpm-pool-wordpress.conf.j2 index e6c654e2a..b7ddd5b58 100644 --- a/roles/wordpress-setup/templates/php-fpm-pool-wordpress.conf.j2 +++ b/roles/wordpress-setup/templates/php-fpm-pool-wordpress.conf.j2 @@ -2,10 +2,11 @@ [wordpress] listen = /var/run/php-fpm-wordpress.sock +; Keep socket owner/group aligned with Nginx's service user so Nginx can always connect to php-fpm. listen.owner = www-data listen.group = www-data -user = {{ web_user }} -group = {{ web_group }} +user = {{ wordpress_runtime_hardened | ternary(wordpress_runtime_user, web_user) }} +group = {{ wordpress_runtime_hardened | ternary(wordpress_runtime_group, web_group) }} pm = {{ php_fpm_pm }} pm.max_children = {{ php_fpm_pm_max_children }} pm.start_servers = {{ php_fpm_pm_start_servers }} diff --git a/tests/templates/render.py b/tests/templates/render.py index 4ad423868..01a12a598 100644 --- a/tests/templates/render.py +++ b/tests/templates/render.py @@ -36,6 +36,7 @@ def trust_as_template(value: str) -> str: DEFAULTS_FILES = ( REPO_ROOT / "roles/nginx/defaults/main.yml", + REPO_ROOT / "roles/php/defaults/main.yml", REPO_ROOT / "roles/wordpress-setup/defaults/main.yml", ) diff --git a/tests/templates/test_php_fpm_pool.py b/tests/templates/test_php_fpm_pool.py new file mode 100644 index 000000000..74f175cbe --- /dev/null +++ b/tests/templates/test_php_fpm_pool.py @@ -0,0 +1,28 @@ +from tests.templates.render import render_template + + +def test_render_php_fpm_pool_default_runtime_user() -> None: + rendered = render_template( + "roles/wordpress-setup/templates/php-fpm-pool-wordpress.conf.j2", + overrides={"web_user": "web", "web_group": "www-data"}, + ) + + assert "user = web" in rendered + assert "group = www-data" in rendered + assert "php_admin_value[open_basedir] = /srv/www/:/tmp" in rendered + + +def test_render_php_fpm_pool_hardened_runtime_user() -> None: + rendered = render_template( + "roles/wordpress-setup/templates/php-fpm-pool-wordpress.conf.j2", + overrides={ + "web_user": "web", + "web_group": "www-data", + "wordpress_runtime_hardened": True, + "wordpress_runtime_user": "php-runner", + "wordpress_runtime_group": "php-runner", + }, + ) + + assert "user = php-runner" in rendered + assert "group = php-runner" in rendered