diff --git a/.docker/nginx/conf.d/default.conf b/.docker/nginx/conf.d/default.conf index dcc3b80..c225ccc 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 ~* ^/wp-content/uploads/.*\.php$ { deny all; } 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 100755 index 0000000..33a0f5a --- /dev/null +++ b/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh @@ -0,0 +1,24 @@ +#!/bin/sh + +set -eu + +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 diff --git a/.env.example b/.env.example index e10b8d0..759ef93 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,9 @@ WORDPRESS_DB_HOST=mariadb WORDPRESS_DB_NAME=wordpress WORDPRESS_DB_USER=root WORDPRESS_DB_PASSWORD=root +# 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 diff --git a/.github/workflows/bats-tests.yml b/.github/workflows/bats-tests.yml new file mode 100644 index 0000000..70717ca --- /dev/null +++ b/.github/workflows/bats-tests.yml @@ -0,0 +1,27 @@ +name: Bats tests + +on: + pull_request: + push: + branches: + - main + +jobs: + bats-tests: + name: Bats infrastructure tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - 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 + uses: ludeeus/action-shellcheck@master + with: + scandir: tests/security/helpers \ No newline at end of file diff --git a/README.md b/README.md index 4a3626c..9cc7eac 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ It is useful when you need to: Prerequisite: [Docker](https://docs.docker.com/get-docker/) must be installed on your operating system. - ## Setup ### Production @@ -82,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 diff --git a/common-services.yml b/common-services.yml index 1823a6a..b8c2e4b 100644 --- a/common-services.yml +++ b/common-services.yml @@ -32,11 +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:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro environment: - DEFAULT_HOST - VIRTUAL_HOST - LETSENCRYPT_HOST - LETSENCRYPT_EMAIL + - WORDPRESS_XMLRPC_ENABLED=${WORDPRESS_XMLRPC_ENABLED:-1} mariadb: build: diff --git a/tests/security/helpers/nginx.bash b/tests/security/helpers/nginx.bash new file mode 100644 index 0000000..845e434 --- /dev/null +++ b/tests/security/helpers/nginx.bash @@ -0,0 +1,170 @@ +#!/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="" +nginx_hardening_network="" +nginx_hardening_php_container="" + +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_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 + 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 +} 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 +} 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"* ]] +}