Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f164c27
security: include XML-RPC hardening config
vitormattos Aug 18, 2026
3f0989a
security: generate XML-RPC policy at startup
vitormattos Aug 18, 2026
7132c3e
security: configure XML-RPC opt-in
vitormattos Aug 18, 2026
3ea948e
docs: document XML-RPC default
vitormattos Aug 18, 2026
6b7bfe7
chore: add new line at end of file
vitormattos Aug 18, 2026
17ff265
fix: validate XML-RPC setting values
vitormattos Aug 18, 2026
7e5368c
fix: preserve nginx entrypoint lifecycle
vitormattos Aug 18, 2026
2dd9295
docs: preserve XML-RPC capability by default
vitormattos Aug 18, 2026
d22a098
test: cover XML-RPC nginx policies
vitormattos Aug 18, 2026
4e32663
ci: run XML-RPC hardening tests
vitormattos Aug 18, 2026
b618e5b
docs: explain XML-RPC deployment policy
vitormattos Aug 18, 2026
895dfdd
docs: keep XML-RPC policy out of README
vitormattos Aug 18, 2026
f88d2d6
docs: show XML-RPC override setting
vitormattos Aug 18, 2026
d79a6ef
ci: run security tests with Bats
vitormattos Aug 18, 2026
ecfa865
test: add reusable nginx Bats helper
vitormattos Aug 18, 2026
1820c64
test: migrate XML-RPC scenarios to Bats
vitormattos Aug 18, 2026
2dd47fc
test: remove legacy XML-RPC runner
vitormattos Aug 18, 2026
7d046ce
ci: check Bats security helpers
vitormattos Aug 18, 2026
4055cf1
test: support real nginx integration
vitormattos Aug 18, 2026
0700515
test: cover real nginx XML-RPC routing
vitormattos Aug 18, 2026
b92cc75
ci: generalize Bats workflow
vitormattos Aug 18, 2026
1e83aff
ci: speed up Bats setup
vitormattos Aug 18, 2026
2bd1800
Merge branch 'main' into hardening/block-xmlrpc-by-default
vitormattos Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .docker/nginx/conf.d/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
24 changes: 24 additions & 0 deletions .docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/bats-tests.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions common-services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
170 changes: 170 additions & 0 deletions tests/security/helpers/nginx.bash
Original file line number Diff line number Diff line change
@@ -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' '<?php echo "PHP_UPSTREAM_REACHED";' > "${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
}
31 changes: 31 additions & 0 deletions tests/security/xmlrpc-integration.bats
Original file line number Diff line number Diff line change
@@ -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
}
62 changes: 62 additions & 0 deletions tests/security/xmlrpc.bats
Original file line number Diff line number Diff line change
@@ -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"* ]]
}
Loading