Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 28 additions & 9 deletions .docker/nginx/conf.d/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,41 @@ server {
try_files $uri $uri/ /index.php$is_args$args;
}
include /tmp/xmlrpc-hardening.conf;
include /tmp/web-install.conf;
location ~* ^/wp-content/uploads/.*\.php$ {
deny all;
}
location ~* ^/wp-content/.*/vendor(-bin)?/.*\.php$ {
deny all;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass wordpress:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
location ~ ^/(index|wp-login|wp-cron|wp-comments-post|wp-signup|wp-activate|wp-trackback|wp-links-opml)\.php$ {
include /etc/nginx/snippets/php-fpm.conf;
}
location ~ /\.(ht|git) {
location ~ ^/wp-admin/([^/]+|(network|user)/[^/]+)\.php$ {
include /etc/nginx/snippets/php-fpm.conf;
}
location = /wp-includes/js/tinymce/wp-tinymce.php {
include /etc/nginx/snippets/php-fpm.conf;
}
location ~* \.(php[0-9]?|phtml|phar|pht)(/|$) {
return 404;
}
location ~ /\.(?!well-known/) {
deny all;
}
location ~* \.(sql|bak|old|orig|save|swp|swo|log|sh|dist|lock|tar|tgz|gz|bz2|xz|7z|rar|wpress)$ {
deny all;
}
location ~ "~$" {
deny all;
}
location ~* "^(?!/wp-content/uploads/\d{4}/\d{2}/).*\.zip$" {
deny all;
}
location ~* ^/wp-content/(debug\.log|updraft|ai1wm-backups|aiowps_backups|backups?[^/]*|upgrade|uploads/(wc-logs|woocommerce_uploads))(/|$) {
deny all;
}
location ~* /(composer\.(json|lock)|package(-lock)?\.json|phpunit[^/]*\.xml(\.dist)?)$ {
deny all;
}
location = /favicon.ico {
Expand Down
6 changes: 4 additions & 2 deletions .docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

set -eu

xmlrpc_enabled="$(printf '%s' "${WORDPRESS_XMLRPC_ENABLED:-1}" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
xmlrpc_enabled="$(printf '%s' "${WORDPRESS_XMLRPC_ENABLED:-0}" | 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.
location = /xmlrpc.php {
include /etc/nginx/snippets/php-fpm.conf;
}
EOF
;;
0|false|no|off)
Expand Down
32 changes: 32 additions & 0 deletions .docker/nginx/docker-entrypoint.d/41-web-install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/bin/sh

set -eu

web_install_enabled="$(printf '%s' "${WORDPRESS_WEB_INSTALL_ENABLED:-0}" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"

case "$web_install_enabled" in
1|true|yes|on)
cat > /tmp/web-install.conf <<'EOF'
location = /wp-admin/install.php {
include /etc/nginx/snippets/php-fpm.conf;
}
location = /wp-admin/setup-config.php {
include /etc/nginx/snippets/php-fpm.conf;
}
EOF
;;
0|false|no|off)
cat > /tmp/web-install.conf <<'EOF'
location = /wp-admin/install.php {
return 404;
}
location = /wp-admin/setup-config.php {
return 404;
}
EOF
;;
*)
echo "ERROR: WORDPRESS_WEB_INSTALL_ENABLED must be one of 1, true, yes, on, 0, false, no, off; got: $web_install_enabled" >&2
exit 1
;;
esac
7 changes: 7 additions & 0 deletions .docker/nginx/snippets/php-fpm.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass wordpress:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
22 changes: 21 additions & 1 deletion .docker/wordpress/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,27 @@ install_git_entries() {
done < <(read_yaml_values "${query}[]? | @base64")
}

install_wordpress_core() {
if wordpress_is_installed; then
return
fi

if [ -z "${WORDPRESS_SITE_URL:-}" ] || [ -z "${WORDPRESS_ADMIN_USER:-}" ] || [ -z "${WORDPRESS_ADMIN_PASSWORD:-}" ] || [ -z "${WORDPRESS_ADMIN_EMAIL:-}" ]; then
return
fi

echo "Installing WordPress core..."
runuser -u www-data -- wp core install \
--url="${WORDPRESS_SITE_URL}" \
--title="${WORDPRESS_SITE_TITLE:-WordPress}" \
--admin_user="${WORDPRESS_ADMIN_USER}" \
--admin_password="${WORDPRESS_ADMIN_PASSWORD}" \
--admin_email="${WORDPRESS_ADMIN_EMAIL}" \
--skip-email
}

wait_for_db
install_wordpress_core

echo "Installing plugins and themes..."

Expand All @@ -376,7 +396,7 @@ if wordpress_is_installed; then
fi
else
echo "WordPress is not installed yet; skipping automatic plugin and theme installation."
echo "After completing the initial setup in the browser, restart the container to run this step."
echo "Set WORDPRESS_SITE_URL, WORDPRESS_ADMIN_USER, WORDPRESS_ADMIN_PASSWORD and WORDPRESS_ADMIN_EMAIL to install it on startup, or enable WORDPRESS_WEB_INSTALL_ENABLED on nginx to use the browser installer."
fi

echo ""
Expand Down
11 changes: 8 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ 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
# XML-RPC and the browser installer are blocked by nginx unless enabled here.
WORDPRESS_XMLRPC_ENABLED=0
WORDPRESS_WEB_INSTALL_ENABLED=0
# Install WordPress on the first start when all four are set.
WORDPRESS_SITE_URL=
WORDPRESS_ADMIN_USER=
WORDPRESS_ADMIN_PASSWORD=
WORDPRESS_ADMIN_EMAIL=

# Maria DB Configuraton
MARIADB_USER=root
Expand Down
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,24 @@ Prerequisite: [Docker](https://docs.docker.com/get-docker/) must be installed on

### Production

After clone:
After clone, set the site URL and the first administrator so the container installs WordPress on the first start:

```bash
WORDPRESS_SITE_URL=https://example.com \
WORDPRESS_ADMIN_USER=admin \
WORDPRESS_ADMIN_PASSWORD='a strong password' \
[email protected] \
docker compose up -d
```

nginx only runs the WordPress entry points through PHP-FPM: the PHP files at the root used by WordPress (`index.php`, `wp-login.php`, `wp-cron.php`...), the scripts directly under `wp-admin/` (and `wp-admin/network/`, `wp-admin/user/`) and `wp-includes/js/tinymce/wp-tinymce.php`. Any other `.php`, `.phtml`, `.phar` or `.pht` file returns 404, including files under `wp-content`.

nginx also returns 403 for files that should never be public: dotfiles (except `.well-known/`), backups and editor leftovers (`.bak`, `.old`, `.orig`, `.save`, `.swp`, `~`), dumps, logs and archives (`.sql`, `.log`, `.tar`, `.gz`, `.7z`, `.rar`, `.wpress`...), `.zip` files outside the media folders `wp-content/uploads/YYYY/MM/`, the backup and log folders of common plugins, and dependency manifests (`composer.json`, `package.json`, `phpunit.xml`...).

These are closed by default and can be enabled on the `nginx` service:
- `WORDPRESS_XMLRPC_ENABLED=1`: serve `xmlrpc.php`.
- `WORDPRESS_WEB_INSTALL_ENABLED=1`: serve `wp-admin/install.php` and `wp-admin/setup-config.php` to finish the installation in the browser.

### Development

Create a local file named `docker-compose.override.yml` in the project root with this content:
Expand Down Expand Up @@ -81,9 +93,6 @@ 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 Expand Up @@ -124,6 +133,14 @@ Every `*.conf` file mounted at `/etc/nginx/server.d/` is included at the end of
client_max_body_size 0;
```

A plugin that needs one of its PHP files reachable directly can be allowed with a `^~` location, which takes precedence over the PHP rules of `default.conf`:

```nginx
location ^~ /wp-content/plugins/my-plugin/endpoint.php {
include /etc/nginx/snippets/php-fpm.conf;
}
```

### Database dump

If you need to bootstrap the environment with existing data, place your SQL dump in the folder below:
Expand Down
10 changes: 9 additions & 1 deletion common-services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ services:
- WORDPRESS_DB_PASSWORD=${WORDPRESS_DB_PASSWORD:-root}
- WORDPRESS_TABLE_PREFIX=${WORDPRESS_TABLE_PREFIX:-wp_}
- WORDPRESS_SETUP_CONFIG_YAML=${WORDPRESS_SETUP_CONFIG_YAML:-}
- WORDPRESS_SITE_URL
- WORDPRESS_SITE_TITLE
- WORDPRESS_ADMIN_USER
- WORDPRESS_ADMIN_PASSWORD
- WORDPRESS_ADMIN_EMAIL
- WORDPRESS_LOCAL_USERS_PASSWORD=${WORDPRESS_LOCAL_USERS_PASSWORD:-}
- WORDPRESS_LOCAL_RESET_ALL_USERS_PASSWORDS=${WORDPRESS_LOCAL_RESET_ALL_USERS_PASSWORDS:-0}
- WORDPRESS_LOCAL_RESET_PASSWORD_FOR_USER=${WORDPRESS_LOCAL_RESET_PASSWORD_FOR_USER:-}
Expand All @@ -32,13 +37,16 @@ services:
volumes:
- ./volumes/wordpress:/var/www/html:ro
- ./.docker/nginx/conf.d/:/etc/nginx/conf.d/
- ./.docker/nginx/snippets/:/etc/nginx/snippets/:ro
- ./.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro
- ./.docker/nginx/docker-entrypoint.d/41-web-install.sh:/docker-entrypoint.d/41-web-install.sh:ro
environment:
- DEFAULT_HOST
- VIRTUAL_HOST
- LETSENCRYPT_HOST
- LETSENCRYPT_EMAIL
- WORDPRESS_XMLRPC_ENABLED=${WORDPRESS_XMLRPC_ENABLED:-1}
- WORDPRESS_XMLRPC_ENABLED=${WORDPRESS_XMLRPC_ENABLED:-0}
- WORDPRESS_WEB_INSTALL_ENABLED=${WORDPRESS_WEB_INSTALL_ENABLED:-0}

mariadb:
build:
Expand Down
14 changes: 12 additions & 2 deletions tests/security/helpers/nginx.bash
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

nginx_hardening_root="${BATS_TEST_DIRNAME}/../.."
nginx_hardening_script="${nginx_hardening_root}/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh"
nginx_web_install_script="${nginx_hardening_root}/.docker/nginx/docker-entrypoint.d/41-web-install.sh"
nginx_hardening_tmp="${BATS_TEST_TMPDIR}/nginx"
nginx_hardening_container=""
nginx_hardening_port=""
Expand All @@ -26,10 +27,11 @@ server {
try_files $uri =404;
}
location ~ \.php$ {
proxy_pass http://127.0.0.1:8081;
include /etc/nginx/snippets/php-fpm.conf;
}
}
NGINX_CONF
printf '%s\n' 'proxy_pass http://127.0.0.1:8081;' > "${nginx_hardening_tmp}/php-fpm.conf"
}

nginx_hardening_logs() {
Expand All @@ -50,6 +52,7 @@ nginx_hardening_start() {
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" \
-v "${nginx_hardening_tmp}/php-fpm.conf:/etc/nginx/snippets/php-fpm.conf:ro" \
-p 127.0.0.1::80 nginx:latest)"

nginx_hardening_port="$(docker port "${nginx_hardening_container}" 80/tcp | sed 's/.*://')"
Expand Down Expand Up @@ -104,10 +107,15 @@ nginx_hardening_server_snippet() {

nginx_hardening_integration_start() {
local value="${1-__UNSET__}"
local web_install_value="${2-__UNSET__}"
local -a environment_args=()

if [ "${value}" != "__UNSET__" ]; then
environment_args=(-e "WORDPRESS_XMLRPC_ENABLED=${value}")
environment_args+=(-e "WORDPRESS_XMLRPC_ENABLED=${value}")
fi

if [ "${web_install_value}" != "__UNSET__" ]; then
environment_args+=(-e "WORDPRESS_WEB_INSTALL_ENABLED=${web_install_value}")
fi

local -a server_snippets_args=()
Expand All @@ -118,7 +126,9 @@ nginx_hardening_integration_start() {
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_root}/.docker/nginx/snippets:/etc/nginx/snippets:ro" \
-v "${nginx_hardening_script}:/docker-entrypoint.d/40-xmlrpc-hardening.sh:ro" \
-v "${nginx_web_install_script}:/docker-entrypoint.d/41-web-install.sh:ro" \
-v "${nginx_hardening_tmp}/document-root:/var/www/html:ro" \
"${server_snippets_args[@]}" \
-p 127.0.0.1::80 nginx:latest)"
Expand Down
4 changes: 2 additions & 2 deletions tests/security/nginx-server-snippets.bats
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ teardown() {
}

@test "real nginx config starts without server snippets" {
nginx_hardening_integration_start
nginx_hardening_integration_start 1
nginx_hardening_config_is_valid

run nginx_hardening_request POST /xmlrpc.php "${nginx_hardening_tmp}/request-body"
Expand All @@ -23,7 +23,7 @@ teardown() {

@test "real nginx config applies server snippets" {
nginx_hardening_server_snippet client-max-body-size.conf 'client_max_body_size 1k;'
nginx_hardening_integration_start
nginx_hardening_integration_start 1
nginx_hardening_config_is_valid

run nginx_hardening_request POST /xmlrpc.php "${nginx_hardening_tmp}/request-body"
Expand Down
Loading
Loading