From f164c27f3856677f0669fbee318fe406769580a9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:16:46 -0300 Subject: [PATCH 01/22] security: include XML-RPC hardening config Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .docker/nginx/conf.d/default.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/.docker/nginx/conf.d/default.conf b/.docker/nginx/conf.d/default.conf index e75d1e6..904a25f 100644 --- a/.docker/nginx/conf.d/default.conf +++ b/.docker/nginx/conf.d/default.conf @@ -21,6 +21,7 @@ server { location / { try_files $uri $uri/ /index.php$is_args$args; } + include /tmp/xmlrpc-hardening.conf; location ~ \.php$ { try_files $uri =404; fastcgi_split_path_info ^(.+\.php)(/.+)$; From 3f0989a6bb3d5f17f34fbcf85a191dd4eb0d9966 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:16:50 -0300 Subject: [PATCH 02/22] security: generate XML-RPC policy at startup Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../docker-entrypoint.d/40-xmlrpc-hardening.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh diff --git a/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh b/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh new file mode 100644 index 0000000..255a05a --- /dev/null +++ b/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +set -eu + +case "${WORDPRESS_XMLRPC_ENABLED:-0}" in + 1|true|yes|on) + cat > /tmp/xmlrpc-hardening.conf <<'EOF' +# XML-RPC explicitly enabled for this environment. +EOF + ;; + *) + cat > /tmp/xmlrpc-hardening.conf <<'EOF' +location = /xmlrpc.php { + return 403; +} +EOF + ;; +esac \ No newline at end of file From 7132c3e4f97c6ba34ccf1bc69a8a6eec08c7abc0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:16:54 -0300 Subject: [PATCH 03/22] security: configure XML-RPC opt-in Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- common-services.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common-services.yml b/common-services.yml index 1823a6a..95df1d8 100644 --- a/common-services.yml +++ b/common-services.yml @@ -32,11 +32,14 @@ services: volumes: - ./volumes/wordpress:/var/www/html:ro - ./.docker/nginx/conf.d/:/etc/nginx/conf.d/ + - ./.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh:/tmp/40-xmlrpc-hardening.sh:ro + command: ["/bin/sh", "-c", ". /tmp/40-xmlrpc-hardening.sh && exec nginx -g 'daemon off;'"] environment: - DEFAULT_HOST - VIRTUAL_HOST - LETSENCRYPT_HOST - LETSENCRYPT_EMAIL + - WORDPRESS_XMLRPC_ENABLED=${WORDPRESS_XMLRPC_ENABLED:-0} mariadb: build: From 3ea948ef1ae3ecfb8e870ae669b9922dca3c0387 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:16:57 -0300 Subject: [PATCH 04/22] docs: document XML-RPC default Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .env.example | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.env.example b/.env.example index e10b8d0..3bd9c32 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ WORDPRESS_DB_HOST=mariadb WORDPRESS_DB_NAME=wordpress WORDPRESS_DB_USER=root WORDPRESS_DB_PASSWORD=root +# XML-RPC is disabled by default; enable explicitly only when required. +WORDPRESS_XMLRPC_ENABLED=0 # Maria DB Configuraton MARIADB_USER=root From 6b7bfe77e42dd197ed167d6544047b476f2a7660 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:21:33 -0300 Subject: [PATCH 05/22] chore: add new line at end of file Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh b/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh index 255a05a..eb0da76 100644 --- a/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh +++ b/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh @@ -15,4 +15,4 @@ location = /xmlrpc.php { } EOF ;; -esac \ No newline at end of file +esac From 17ff265dbd2f1fed7993781ba095c41c829898d4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:12:31 -0300 Subject: [PATCH 06/22] fix: validate XML-RPC setting values Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) mode change 100644 => 100755 .docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh diff --git a/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh b/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh old mode 100644 new mode 100755 index eb0da76..33a0f5a --- a/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh +++ b/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh @@ -2,17 +2,23 @@ set -eu -case "${WORDPRESS_XMLRPC_ENABLED:-0}" in +xmlrpc_enabled="$(printf '%s' "${WORDPRESS_XMLRPC_ENABLED:-1}" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" + +case "$xmlrpc_enabled" in 1|true|yes|on) cat > /tmp/xmlrpc-hardening.conf <<'EOF' # XML-RPC explicitly enabled for this environment. EOF ;; - *) + 0|false|no|off) cat > /tmp/xmlrpc-hardening.conf <<'EOF' location = /xmlrpc.php { return 403; } EOF ;; + *) + echo "ERROR: WORDPRESS_XMLRPC_ENABLED must be one of 1, true, yes, on, 0, false, no, off; got: $xmlrpc_enabled" >&2 + exit 1 + ;; esac From 7e5368c4653420a076573359adc01268617a0420 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:12:34 -0300 Subject: [PATCH 07/22] fix: preserve nginx entrypoint lifecycle Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- common-services.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/common-services.yml b/common-services.yml index 95df1d8..b8c2e4b 100644 --- a/common-services.yml +++ b/common-services.yml @@ -32,14 +32,13 @@ services: volumes: - ./volumes/wordpress:/var/www/html:ro - ./.docker/nginx/conf.d/:/etc/nginx/conf.d/ - - ./.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh:/tmp/40-xmlrpc-hardening.sh:ro - command: ["/bin/sh", "-c", ". /tmp/40-xmlrpc-hardening.sh && exec nginx -g 'daemon off;'"] + - ./.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro environment: - DEFAULT_HOST - VIRTUAL_HOST - LETSENCRYPT_HOST - LETSENCRYPT_EMAIL - - WORDPRESS_XMLRPC_ENABLED=${WORDPRESS_XMLRPC_ENABLED:-0} + - WORDPRESS_XMLRPC_ENABLED=${WORDPRESS_XMLRPC_ENABLED:-1} mariadb: build: From 2dd929522a59fc5ac638beefe91922d804bc7e0d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:12:38 -0300 Subject: [PATCH 08/22] docs: preserve XML-RPC capability by default Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .env.example | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 3bd9c32..759ef93 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,9 @@ WORDPRESS_DB_HOST=mariadb WORDPRESS_DB_NAME=wordpress WORDPRESS_DB_USER=root WORDPRESS_DB_PASSWORD=root -# XML-RPC is disabled by default; enable explicitly only when required. -WORDPRESS_XMLRPC_ENABLED=0 +# Preserve the upstream WordPress capability by default. Hardened deployments +# should explicitly set WORDPRESS_XMLRPC_ENABLED=0. +WORDPRESS_XMLRPC_ENABLED=1 # Maria DB Configuraton MARIADB_USER=root From d22a0989f4b89bb54ca017b8fe987ceb012e17c0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:12:42 -0300 Subject: [PATCH 09/22] test: cover XML-RPC nginx policies Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/security/test-xmlrpc-nginx.sh | 111 ++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100755 tests/security/test-xmlrpc-nginx.sh diff --git a/tests/security/test-xmlrpc-nginx.sh b/tests/security/test-xmlrpc-nginx.sh new file mode 100755 index 0000000..b7458d0 --- /dev/null +++ b/tests/security/test-xmlrpc-nginx.sh @@ -0,0 +1,111 @@ +#!/bin/sh + +set -eu + +project_root="$(cd "$(dirname "$0")/../.." && pwd)" +script="$project_root/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh" +fixture_dir="$(mktemp -d)" +container_id="" + +cleanup() { + if [ -n "$container_id" ]; then + docker rm -f "$container_id" >/dev/null 2>&1 || true + fi + rm -rf "$fixture_dir" +} +trap cleanup EXIT INT TERM + +cat > "$fixture_dir/default.conf" <<'NGINX_CONF' +server { + listen 8081; + location / { + return 200 "PHP_UPSTREAM_REACHED\n"; + } +} + +server { + listen 80; + root /var/www/html; + include /tmp/xmlrpc-hardening.conf; + location / { + try_files $uri =404; + } + location ~ \.php$ { + proxy_pass http://127.0.0.1:8081; + } +} +NGINX_CONF + +run_allowed_case() { + name="$1" + value="$2" + + echo "Running XML-RPC case: $name" + if [ "$value" = "__UNSET__" ]; then + container_id="$(docker run -d --rm \ + -v "$script:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro" \ + -v "$fixture_dir/default.conf:/etc/nginx/conf.d/default.conf:ro" \ + -p 127.0.0.1::80 nginx:latest)" + else + container_id="$(docker run -d --rm -e WORDPRESS_XMLRPC_ENABLED="$value" \ + -v "$script:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro" \ + -v "$fixture_dir/default.conf:/etc/nginx/conf.d/default.conf:ro" \ + -p 127.0.0.1::80 nginx:latest)" + fi + port="$(docker port "$container_id" 80/tcp | sed 's/.*://')" + + tries=0 + while ! curl -fsS "http://127.0.0.1:$port/xmlrpc.php" >/dev/null 2>&1; do + tries=$((tries + 1)) + if [ "$tries" -ge 30 ]; then + docker logs "$container_id" >&2 || true + return 1 + fi + sleep 1 + done + + docker exec "$container_id" nginx -t + status="$(curl -sS -o "$fixture_dir/body" -w '%{http_code}' "http://127.0.0.1:$port/xmlrpc.php")" + test "$status" = 200 + grep -q PHP_UPSTREAM_REACHED "$fixture_dir/body" + + docker rm -f "$container_id" >/dev/null + container_id="" +} + +run_allowed_case "variable absent" __UNSET__ +run_allowed_case "explicit enabled" 1 + +echo "Running XML-RPC case: explicit disabled" +container_id="$(docker run -d --rm -e WORDPRESS_XMLRPC_ENABLED=0 \ + -v "$script:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro" \ + -v "$fixture_dir/default.conf:/etc/nginx/conf.d/default.conf:ro" \ + -p 127.0.0.1::80 nginx:latest)" +port="$(docker port "$container_id" 80/tcp | sed 's/.*://')" +tries=0 +while ! docker exec "$container_id" nginx -t >/dev/null 2>&1; do + tries=$((tries + 1)) + if [ "$tries" -ge 30 ]; then + docker logs "$container_id" >&2 || true + exit 1 + fi + sleep 1 +done + +status="$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/xmlrpc.php")" +test "$status" = 403 +status="$(curl -sS -o /dev/null -w '%{http_code}' -X POST -d security-test "http://127.0.0.1:$port/xmlrpc.php")" +test "$status" = 403 +docker rm -f "$container_id" >/dev/null +container_id="" + +echo "Running XML-RPC case: invalid value" +if docker run --rm -e WORDPRESS_XMLRPC_ENABLED=invalid \ + -v "$script:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro" nginx:latest \ + >"$fixture_dir/invalid.stdout" 2>"$fixture_dir/invalid.stderr"; then + echo "ERROR: invalid value was accepted" >&2 + exit 1 +fi +grep -q 'ERROR: WORDPRESS_XMLRPC_ENABLED must be one of' "$fixture_dir/invalid.stderr" + +echo "XML-RPC nginx tests: PASS" From 4e32663ec297649cb65254606fcd84c7de9e3b83 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:12:46 -0300 Subject: [PATCH 10/22] ci: run XML-RPC hardening tests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/security-hardening.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/security-hardening.yml diff --git a/.github/workflows/security-hardening.yml b/.github/workflows/security-hardening.yml new file mode 100644 index 0000000..2f442fe --- /dev/null +++ b/.github/workflows/security-hardening.yml @@ -0,0 +1,16 @@ +name: Security hardening + +on: + pull_request: + push: + branches: + - main + +jobs: + nginx-hardening: + name: Nginx XML-RPC policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run XML-RPC nginx tests + run: ./tests/security/test-xmlrpc-nginx.sh From b618e5ba0c6093a44db9228a9542b4db0ee8435e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:12:50 -0300 Subject: [PATCH 11/22] docs: explain XML-RPC deployment policy Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index 4a3626c..86c1125 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,31 @@ It is useful when you need to: Prerequisite: [Docker](https://docs.docker.com/get-docker/) must be installed on your operating system. +## Security / Hardening + +The nginx service preserves the upstream WordPress XML-RPC capability by default. +Set `WORDPRESS_XMLRPC_ENABLED=0` in a hardened deployment to block both GET and +POST requests to `/xmlrpc.php` before they reach PHP-FPM. Set it to `1` (or +`true`, `yes`, or `on`) to preserve XML-RPC. The values `0`, `false`, `no`, and +`off` disable it; any other value fails nginx startup with an error. + +The XML-RPC policy is applied by the standard nginx `/docker-entrypoint.d/` +lifecycle. The image's original entrypoint and command are preserved. This +repository does not install a MU-plugin or remove XML-RPC methods globally. + +The nginx configuration also blocks PHP execution in `wp-content/uploads`; +normal media files remain accessible. This is an HTTP-layer control and does +not replace filesystem isolation in production. + +Run the functional regression tests with: + +```sh +./tests/security/test-xmlrpc-nginx.sh +``` + +The test covers an unset variable, both explicit policies, GET and POST blocking, +upstream reachability when enabled, and startup failure for invalid values. + ## Setup From 895dfdd8195ff13132700a20a914f6bedae40754 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:14:44 -0300 Subject: [PATCH 12/22] docs: keep XML-RPC policy out of README Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- README.md | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/README.md b/README.md index 86c1125..7754322 100644 --- a/README.md +++ b/README.md @@ -12,32 +12,6 @@ It is useful when you need to: Prerequisite: [Docker](https://docs.docker.com/get-docker/) must be installed on your operating system. -## Security / Hardening - -The nginx service preserves the upstream WordPress XML-RPC capability by default. -Set `WORDPRESS_XMLRPC_ENABLED=0` in a hardened deployment to block both GET and -POST requests to `/xmlrpc.php` before they reach PHP-FPM. Set it to `1` (or -`true`, `yes`, or `on`) to preserve XML-RPC. The values `0`, `false`, `no`, and -`off` disable it; any other value fails nginx startup with an error. - -The XML-RPC policy is applied by the standard nginx `/docker-entrypoint.d/` -lifecycle. The image's original entrypoint and command are preserved. This -repository does not install a MU-plugin or remove XML-RPC methods globally. - -The nginx configuration also blocks PHP execution in `wp-content/uploads`; -normal media files remain accessible. This is an HTTP-layer control and does -not replace filesystem isolation in production. - -Run the functional regression tests with: - -```sh -./tests/security/test-xmlrpc-nginx.sh -``` - -The test covers an unset variable, both explicit policies, GET and POST blocking, -upstream reachability when enabled, and startup failure for invalid values. - - ## Setup ### Production From f88d2d678adb10d5ba36f2b9bb60ad5b2eedf3d5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:24:03 -0300 Subject: [PATCH 13/22] docs: show XML-RPC override setting Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 7754322..9cc7eac 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,9 @@ services: source: https://github.com/org/my-theme.git nginx: + environment: + # Hardened deployment example: block XML-RPC before PHP-FPM. + WORDPRESS_XMLRPC_ENABLED: ${WORDPRESS_XMLRPC_ENABLED:-0} ports: - 127.0.0.1:80:80 From d79a6ef1183c3b01df3c45a5d3896e72f5481ab0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:48:01 -0300 Subject: [PATCH 14/22] ci: run security tests with Bats Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/security-hardening.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-hardening.yml b/.github/workflows/security-hardening.yml index 2f442fe..5508889 100644 --- a/.github/workflows/security-hardening.yml +++ b/.github/workflows/security-hardening.yml @@ -12,5 +12,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Run XML-RPC nginx tests - run: ./tests/security/test-xmlrpc-nginx.sh + - name: Install Bats-core + run: | + sudo apt-get update + sudo apt-get install --yes bats + - name: Run security tests + run: bats tests/security From ecfa865b3dcf17a5ed9e41a62589e07dc644219a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:48:05 -0300 Subject: [PATCH 15/22] test: add reusable nginx Bats helper Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/security/helpers/nginx.bash | 114 ++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/security/helpers/nginx.bash diff --git a/tests/security/helpers/nginx.bash b/tests/security/helpers/nginx.bash new file mode 100644 index 0000000..f9c1939 --- /dev/null +++ b/tests/security/helpers/nginx.bash @@ -0,0 +1,114 @@ +#!/usr/bin/env bash + +nginx_hardening_root="${BATS_TEST_DIRNAME}/../.." +nginx_hardening_script="${nginx_hardening_root}/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh" +nginx_hardening_tmp="${BATS_TEST_TMPDIR}/nginx" +nginx_hardening_container="" +nginx_hardening_port="" + +setup_nginx_fixture() { + mkdir -p "${nginx_hardening_tmp}" + cat > "${nginx_hardening_tmp}/default.conf" <<'NGINX_CONF' +server { + listen 8081; + location / { + return 200 "PHP_UPSTREAM_REACHED\n"; + } +} + +server { + listen 80; + root /var/www/html; + include /tmp/xmlrpc-hardening.conf; + location / { + try_files $uri =404; + } + location ~ \.php$ { + proxy_pass http://127.0.0.1:8081; + } +} +NGINX_CONF +} + +nginx_hardening_logs() { + if [ -n "${nginx_hardening_container}" ]; then + echo "--- nginx container logs (${nginx_hardening_container}) ---" >&2 + docker logs "${nginx_hardening_container}" >&2 || true + fi +} + +nginx_hardening_start() { + local value="${1-__UNSET__}" + local -a environment_args=() + + if [ "${value}" != "__UNSET__" ]; then + environment_args=(-e "WORDPRESS_XMLRPC_ENABLED=${value}") + fi + + nginx_hardening_container="$(docker run -d --rm "${environment_args[@]}" \ + -v "${nginx_hardening_script}:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro" \ + -v "${nginx_hardening_tmp}/default.conf:/etc/nginx/conf.d/default.conf:ro" \ + -p 127.0.0.1::80 nginx:latest)" + + nginx_hardening_port="$(docker port "${nginx_hardening_container}" 80/tcp | sed 's/.*://')" + + local attempts=0 + while ! curl -sS -o /dev/null "http://127.0.0.1:${nginx_hardening_port}/xmlrpc.php" >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "${attempts}" -ge 30 ]; then + nginx_hardening_logs + return 1 + fi + sleep 1 + done +} + +nginx_hardening_stop() { + if [ -n "${nginx_hardening_container}" ]; then + docker rm -f "${nginx_hardening_container}" >/dev/null 2>&1 || true + nginx_hardening_container="" + fi +} + +nginx_hardening_config_is_valid() { + docker exec "${nginx_hardening_container}" nginx -t >/dev/null || { + nginx_hardening_logs + return 1 + } +} + +nginx_hardening_request() { + local method="$1" + local path="$2" + local body_file="${nginx_hardening_tmp}/response-body" + + curl -sS -X "${method}" -o "${body_file}" -w '%{http_code}' \ + "http://127.0.0.1:${nginx_hardening_port}${path}" +} + +nginx_hardening_assert_status() { + local expected="$1" + local actual="$2" + + if [ "${actual}" != "${expected}" ]; then + echo "Expected HTTP ${expected}, got ${actual}" >&2 + nginx_hardening_logs + return 1 + fi +} + +nginx_hardening_assert_upstream_reached() { + if ! grep -q 'PHP_UPSTREAM_REACHED' "${nginx_hardening_tmp}/response-body"; then + echo "Expected the controlled PHP upstream to be reached" >&2 + nginx_hardening_logs + return 1 + fi +} + +nginx_hardening_assert_upstream_not_reached() { + if grep -q 'PHP_UPSTREAM_REACHED' "${nginx_hardening_tmp}/response-body"; then + echo "The controlled PHP upstream was reached unexpectedly" >&2 + nginx_hardening_logs + return 1 + fi +} From 1820c64a162e661c0a981f463c5179c55f2c7155 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:48:09 -0300 Subject: [PATCH 16/22] test: migrate XML-RPC scenarios to Bats Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/security/xmlrpc.bats | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/security/xmlrpc.bats diff --git a/tests/security/xmlrpc.bats b/tests/security/xmlrpc.bats new file mode 100644 index 0000000..14f4bff --- /dev/null +++ b/tests/security/xmlrpc.bats @@ -0,0 +1,62 @@ +#!/usr/bin/env bats + +load 'helpers/nginx.bash' + +setup() { + setup_nginx_fixture +} + +teardown() { + nginx_hardening_stop +} + +@test "XML-RPC is allowed when the variable is absent" { + nginx_hardening_start + nginx_hardening_config_is_valid + + run nginx_hardening_request GET /xmlrpc.php + [ "${status}" -eq 0 ] + nginx_hardening_assert_status 200 "${output}" + nginx_hardening_assert_upstream_reached +} + +@test "XML-RPC is allowed when explicitly enabled" { + nginx_hardening_start 1 + nginx_hardening_config_is_valid + + run nginx_hardening_request GET /xmlrpc.php + [ "${status}" -eq 0 ] + nginx_hardening_assert_status 200 "${output}" + nginx_hardening_assert_upstream_reached +} + +@test "XML-RPC GET is blocked when disabled" { + nginx_hardening_start 0 + nginx_hardening_config_is_valid + + run nginx_hardening_request GET /xmlrpc.php + [ "${status}" -eq 0 ] + nginx_hardening_assert_status 403 "${output}" + nginx_hardening_assert_upstream_not_reached +} + +@test "XML-RPC POST is blocked when disabled" { + nginx_hardening_start 0 + nginx_hardening_config_is_valid + + run curl -sS -X POST -d security-test \ + -o "${nginx_hardening_tmp}/response-body" \ + -w '%{http_code}' "http://127.0.0.1:${nginx_hardening_port}/xmlrpc.php" + [ "${status}" -eq 0 ] + nginx_hardening_assert_status 403 "${output}" + nginx_hardening_assert_upstream_not_reached +} + +@test "XML-RPC rejects an invalid configuration value" { + run docker run --rm -e WORDPRESS_XMLRPC_ENABLED=invalid \ + -v "${nginx_hardening_script}:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro" \ + nginx:latest + + [ "${status}" -ne 0 ] + [[ "${output}" == *"ERROR: WORDPRESS_XMLRPC_ENABLED must be one of"* ]] +} From 2dd47fc25b037c5504a6c91491778b575d4149ae Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:48:13 -0300 Subject: [PATCH 17/22] test: remove legacy XML-RPC runner Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/security/test-xmlrpc-nginx.sh | 111 ---------------------------- 1 file changed, 111 deletions(-) delete mode 100755 tests/security/test-xmlrpc-nginx.sh diff --git a/tests/security/test-xmlrpc-nginx.sh b/tests/security/test-xmlrpc-nginx.sh deleted file mode 100755 index b7458d0..0000000 --- a/tests/security/test-xmlrpc-nginx.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/bin/sh - -set -eu - -project_root="$(cd "$(dirname "$0")/../.." && pwd)" -script="$project_root/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh" -fixture_dir="$(mktemp -d)" -container_id="" - -cleanup() { - if [ -n "$container_id" ]; then - docker rm -f "$container_id" >/dev/null 2>&1 || true - fi - rm -rf "$fixture_dir" -} -trap cleanup EXIT INT TERM - -cat > "$fixture_dir/default.conf" <<'NGINX_CONF' -server { - listen 8081; - location / { - return 200 "PHP_UPSTREAM_REACHED\n"; - } -} - -server { - listen 80; - root /var/www/html; - include /tmp/xmlrpc-hardening.conf; - location / { - try_files $uri =404; - } - location ~ \.php$ { - proxy_pass http://127.0.0.1:8081; - } -} -NGINX_CONF - -run_allowed_case() { - name="$1" - value="$2" - - echo "Running XML-RPC case: $name" - if [ "$value" = "__UNSET__" ]; then - container_id="$(docker run -d --rm \ - -v "$script:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro" \ - -v "$fixture_dir/default.conf:/etc/nginx/conf.d/default.conf:ro" \ - -p 127.0.0.1::80 nginx:latest)" - else - container_id="$(docker run -d --rm -e WORDPRESS_XMLRPC_ENABLED="$value" \ - -v "$script:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro" \ - -v "$fixture_dir/default.conf:/etc/nginx/conf.d/default.conf:ro" \ - -p 127.0.0.1::80 nginx:latest)" - fi - port="$(docker port "$container_id" 80/tcp | sed 's/.*://')" - - tries=0 - while ! curl -fsS "http://127.0.0.1:$port/xmlrpc.php" >/dev/null 2>&1; do - tries=$((tries + 1)) - if [ "$tries" -ge 30 ]; then - docker logs "$container_id" >&2 || true - return 1 - fi - sleep 1 - done - - docker exec "$container_id" nginx -t - status="$(curl -sS -o "$fixture_dir/body" -w '%{http_code}' "http://127.0.0.1:$port/xmlrpc.php")" - test "$status" = 200 - grep -q PHP_UPSTREAM_REACHED "$fixture_dir/body" - - docker rm -f "$container_id" >/dev/null - container_id="" -} - -run_allowed_case "variable absent" __UNSET__ -run_allowed_case "explicit enabled" 1 - -echo "Running XML-RPC case: explicit disabled" -container_id="$(docker run -d --rm -e WORDPRESS_XMLRPC_ENABLED=0 \ - -v "$script:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro" \ - -v "$fixture_dir/default.conf:/etc/nginx/conf.d/default.conf:ro" \ - -p 127.0.0.1::80 nginx:latest)" -port="$(docker port "$container_id" 80/tcp | sed 's/.*://')" -tries=0 -while ! docker exec "$container_id" nginx -t >/dev/null 2>&1; do - tries=$((tries + 1)) - if [ "$tries" -ge 30 ]; then - docker logs "$container_id" >&2 || true - exit 1 - fi - sleep 1 -done - -status="$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/xmlrpc.php")" -test "$status" = 403 -status="$(curl -sS -o /dev/null -w '%{http_code}' -X POST -d security-test "http://127.0.0.1:$port/xmlrpc.php")" -test "$status" = 403 -docker rm -f "$container_id" >/dev/null -container_id="" - -echo "Running XML-RPC case: invalid value" -if docker run --rm -e WORDPRESS_XMLRPC_ENABLED=invalid \ - -v "$script:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro" nginx:latest \ - >"$fixture_dir/invalid.stdout" 2>"$fixture_dir/invalid.stderr"; then - echo "ERROR: invalid value was accepted" >&2 - exit 1 -fi -grep -q 'ERROR: WORDPRESS_XMLRPC_ENABLED must be one of' "$fixture_dir/invalid.stderr" - -echo "XML-RPC nginx tests: PASS" From 7d046cecb3fba9280590925d65ffba0ac0eb5bd9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:21:43 -0300 Subject: [PATCH 18/22] ci: check Bats security helpers Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/security-hardening.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/security-hardening.yml b/.github/workflows/security-hardening.yml index 5508889..81dd3ff 100644 --- a/.github/workflows/security-hardening.yml +++ b/.github/workflows/security-hardening.yml @@ -15,6 +15,8 @@ jobs: - name: Install Bats-core run: | sudo apt-get update - sudo apt-get install --yes bats + sudo apt-get install --yes bats shellcheck - name: Run security tests run: bats tests/security + - name: ShellCheck security helpers + run: shellcheck tests/security/helpers/*.bash From 4055cf1bc76b217196d0269056e7c9ab064d9402 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:21:47 -0300 Subject: [PATCH 19/22] test: support real nginx integration Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/security/helpers/nginx.bash | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/security/helpers/nginx.bash b/tests/security/helpers/nginx.bash index f9c1939..845e434 100644 --- a/tests/security/helpers/nginx.bash +++ b/tests/security/helpers/nginx.bash @@ -5,6 +5,8 @@ nginx_hardening_script="${nginx_hardening_root}/.docker/nginx/docker-entrypoint. nginx_hardening_tmp="${BATS_TEST_TMPDIR}/nginx" nginx_hardening_container="" nginx_hardening_port="" +nginx_hardening_network="" +nginx_hardening_php_container="" setup_nginx_fixture() { mkdir -p "${nginx_hardening_tmp}" @@ -70,6 +72,60 @@ nginx_hardening_stop() { fi } +nginx_hardening_integration_setup() { + nginx_hardening_network="xmlrpc-bats-${BATS_TEST_NUMBER:-0}-$$" + nginx_hardening_tmp="${BATS_TEST_TMPDIR}/nginx-integration" + mkdir -p "${nginx_hardening_tmp}/document-root" + printf '%s\n' ' "${nginx_hardening_tmp}/document-root/xmlrpc.php" + docker network create "${nginx_hardening_network}" >/dev/null + + nginx_hardening_php_container="$(docker run -d --rm \ + --network "${nginx_hardening_network}" --network-alias wordpress \ + -v "${nginx_hardening_tmp}/document-root:/var/www/html:ro" \ + php:8.3-fpm)" +} + +nginx_hardening_integration_start() { + local value="${1-__UNSET__}" + local -a environment_args=() + + if [ "${value}" != "__UNSET__" ]; then + environment_args=(-e "WORDPRESS_XMLRPC_ENABLED=${value}") + fi + + nginx_hardening_container="$(docker run -d --rm "${environment_args[@]}" \ + --network "${nginx_hardening_network}" \ + -v "${nginx_hardening_root}/.docker/nginx/conf.d/default.conf:/etc/nginx/conf.d/default.conf:ro" \ + -v "${nginx_hardening_script}:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro" \ + -v "${nginx_hardening_tmp}/document-root:/var/www/html:ro" \ + -p 127.0.0.1::80 nginx:latest)" + + nginx_hardening_port="$(docker port "${nginx_hardening_container}" 80/tcp | sed 's/.*://')" + + local attempts=0 + while ! curl -sS -o /dev/null "http://127.0.0.1:${nginx_hardening_port}/xmlrpc.php" >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "${attempts}" -ge 30 ]; then + nginx_hardening_logs + docker logs "${nginx_hardening_php_container}" >&2 || true + return 1 + fi + sleep 1 + done +} + +nginx_hardening_integration_stop() { + nginx_hardening_stop + if [ -n "${nginx_hardening_php_container}" ]; then + docker rm -f "${nginx_hardening_php_container}" >/dev/null 2>&1 || true + nginx_hardening_php_container="" + fi + if [ -n "${nginx_hardening_network}" ]; then + docker network rm "${nginx_hardening_network}" >/dev/null 2>&1 || true + nginx_hardening_network="" + fi +} + nginx_hardening_config_is_valid() { docker exec "${nginx_hardening_container}" nginx -t >/dev/null || { nginx_hardening_logs From 0700515756be052d11b0d41836fd1e89d6e81a9c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:21:54 -0300 Subject: [PATCH 20/22] test: cover real nginx XML-RPC routing Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/security/xmlrpc-integration.bats | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/security/xmlrpc-integration.bats diff --git a/tests/security/xmlrpc-integration.bats b/tests/security/xmlrpc-integration.bats new file mode 100644 index 0000000..63cb742 --- /dev/null +++ b/tests/security/xmlrpc-integration.bats @@ -0,0 +1,31 @@ +#!/usr/bin/env bats + +load 'helpers/nginx.bash' + +setup() { + nginx_hardening_integration_setup +} + +teardown() { + nginx_hardening_integration_stop +} + +@test "real nginx config allows XML-RPC by default through PHP-FPM" { + nginx_hardening_integration_start + nginx_hardening_config_is_valid + + run nginx_hardening_request GET /xmlrpc.php + [ "${status}" -eq 0 ] + nginx_hardening_assert_status 200 "${output}" + nginx_hardening_assert_upstream_reached +} + +@test "real nginx config blocks XML-RPC before PHP-FPM when disabled" { + nginx_hardening_integration_start 0 + nginx_hardening_config_is_valid + + run nginx_hardening_request GET /xmlrpc.php + [ "${status}" -eq 0 ] + nginx_hardening_assert_status 403 "${output}" + nginx_hardening_assert_upstream_not_reached +} From b92cc75e988b2b613a7c8b517c93170f78f71fe4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:35:28 -0300 Subject: [PATCH 21/22] ci: generalize Bats workflow Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../{security-hardening.yml => bats-tests.yml} | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) rename .github/workflows/{security-hardening.yml => bats-tests.yml} (59%) diff --git a/.github/workflows/security-hardening.yml b/.github/workflows/bats-tests.yml similarity index 59% rename from .github/workflows/security-hardening.yml rename to .github/workflows/bats-tests.yml index 81dd3ff..9ed06dd 100644 --- a/.github/workflows/security-hardening.yml +++ b/.github/workflows/bats-tests.yml @@ -1,4 +1,4 @@ -name: Security hardening +name: Bats tests on: pull_request: @@ -7,8 +7,8 @@ on: - main jobs: - nginx-hardening: - name: Nginx XML-RPC policy + bats-tests: + name: Bats infrastructure tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -16,7 +16,7 @@ jobs: run: | sudo apt-get update sudo apt-get install --yes bats shellcheck - - name: Run security tests + - name: Run Bats tests run: bats tests/security - - name: ShellCheck security helpers - run: shellcheck tests/security/helpers/*.bash + - name: ShellCheck Bats helpers + run: shellcheck tests/security/helpers/*.bash \ No newline at end of file From 1e83affaf7846fccf112a2c7a83ba0fb0aa0f213 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:52:20 -0300 Subject: [PATCH 22/22] ci: speed up Bats setup Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/bats-tests.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/bats-tests.yml b/.github/workflows/bats-tests.yml index 9ed06dd..70717ca 100644 --- a/.github/workflows/bats-tests.yml +++ b/.github/workflows/bats-tests.yml @@ -12,11 +12,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Install Bats-core - run: | - sudo apt-get update - sudo apt-get install --yes bats shellcheck + - name: Setup Bats-core + uses: bats-core/bats-action@4.0.0 + with: + support-install: false + assert-install: false + detik-install: false + file-install: false - name: Run Bats tests run: bats tests/security - name: ShellCheck Bats helpers - run: shellcheck tests/security/helpers/*.bash \ No newline at end of file + uses: ludeeus/action-shellcheck@master + with: + scandir: tests/security/helpers \ No newline at end of file