From e65f13db6fcc1d1e708e4521be1180838f28dca9 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Sat, 14 Nov 2026 09:59:00 -0400 Subject: [PATCH 1/2] fix: run only WordPress entry points through PHP-FPM Signed-off-by: YvesCesar --- .docker/nginx/conf.d/default.conf | 20 ++- .../40-xmlrpc-hardening.sh | 6 +- .../docker-entrypoint.d/41-web-install.sh | 32 ++++ .docker/nginx/snippets/php-fpm.conf | 7 + .docker/wordpress/entrypoint.sh | 22 ++- .env.example | 11 +- README.md | 23 ++- common-services.yml | 10 +- tests/security/helpers/nginx.bash | 14 +- tests/security/nginx-server-snippets.bats | 4 +- tests/security/php-allowlist.bats | 145 ++++++++++++++++++ tests/security/vendor-php.bats | 6 +- tests/security/xmlrpc-integration.bats | 12 +- tests/security/xmlrpc.bats | 6 +- 14 files changed, 288 insertions(+), 30 deletions(-) create mode 100755 .docker/nginx/docker-entrypoint.d/41-web-install.sh create mode 100644 .docker/nginx/snippets/php-fpm.conf create mode 100644 tests/security/php-allowlist.bats diff --git a/.docker/nginx/conf.d/default.conf b/.docker/nginx/conf.d/default.conf index 2285abb..3a2c9c1 100644 --- a/.docker/nginx/conf.d/default.conf +++ b/.docker/nginx/conf.d/default.conf @@ -22,20 +22,24 @@ 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 ~ ^/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 ~ /\.(ht|git) { deny all; diff --git a/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh b/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh index 33a0f5a..52a3b9d 100755 --- a/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh +++ b/.docker/nginx/docker-entrypoint.d/40-xmlrpc-hardening.sh @@ -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) diff --git a/.docker/nginx/docker-entrypoint.d/41-web-install.sh b/.docker/nginx/docker-entrypoint.d/41-web-install.sh new file mode 100755 index 0000000..5f72743 --- /dev/null +++ b/.docker/nginx/docker-entrypoint.d/41-web-install.sh @@ -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 diff --git a/.docker/nginx/snippets/php-fpm.conf b/.docker/nginx/snippets/php-fpm.conf new file mode 100644 index 0000000..c2d0fda --- /dev/null +++ b/.docker/nginx/snippets/php-fpm.conf @@ -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; diff --git a/.docker/wordpress/entrypoint.sh b/.docker/wordpress/entrypoint.sh index b596548..1090b2a 100644 --- a/.docker/wordpress/entrypoint.sh +++ b/.docker/wordpress/entrypoint.sh @@ -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..." @@ -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 "" diff --git a/.env.example b/.env.example index 759ef93..2ba4a46 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index a3fcfaa..5e06d79 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,22 @@ 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' \ +WORDPRESS_ADMIN_EMAIL=admin@example.com \ 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`. + +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: @@ -81,9 +91,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 @@ -124,6 +131,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: diff --git a/common-services.yml b/common-services.yml index 1d45bfc..6cb37b9 100644 --- a/common-services.yml +++ b/common-services.yml @@ -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:-} @@ -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: diff --git a/tests/security/helpers/nginx.bash b/tests/security/helpers/nginx.bash index a4f2d53..c1d5b3d 100644 --- a/tests/security/helpers/nginx.bash +++ b/tests/security/helpers/nginx.bash @@ -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="" @@ -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() { @@ -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/.*://')" @@ -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=() @@ -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)" diff --git a/tests/security/nginx-server-snippets.bats b/tests/security/nginx-server-snippets.bats index a8f2884..211c56b 100644 --- a/tests/security/nginx-server-snippets.bats +++ b/tests/security/nginx-server-snippets.bats @@ -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" @@ -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" diff --git a/tests/security/php-allowlist.bats b/tests/security/php-allowlist.bats new file mode 100644 index 0000000..8ded5c6 --- /dev/null +++ b/tests/security/php-allowlist.bats @@ -0,0 +1,145 @@ +#!/usr/bin/env bats + +load 'helpers/nginx.bash' + +php_script=' Date: Sat, 14 Nov 2026 10:00:53 -0400 Subject: [PATCH 2/2] fix: deny backups, dumps, logs and dotfiles in nginx Signed-off-by: YvesCesar --- .docker/nginx/conf.d/default.conf | 17 ++++- README.md | 2 + tests/security/sensitive-files.bats | 112 ++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 tests/security/sensitive-files.bats diff --git a/.docker/nginx/conf.d/default.conf b/.docker/nginx/conf.d/default.conf index 3a2c9c1..b619a02 100644 --- a/.docker/nginx/conf.d/default.conf +++ b/.docker/nginx/conf.d/default.conf @@ -41,7 +41,22 @@ server { location ~* \.(php[0-9]?|phtml|phar|pht)(/|$) { return 404; } - location ~ /\.(ht|git) { + 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 { diff --git a/README.md b/README.md index 5e06d79..4c49e26 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ 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. diff --git a/tests/security/sensitive-files.bats b/tests/security/sensitive-files.bats new file mode 100644 index 0000000..a053fb7 --- /dev/null +++ b/tests/security/sensitive-files.bats @@ -0,0 +1,112 @@ +#!/usr/bin/env bats + +load 'helpers/nginx.bash' + +secret='SENSITIVE_CONTENT_SERVED' + +setup() { + nginx_hardening_integration_setup + for path in \ + wp-config.php.bak \ + wp-config.php.old \ + wp-config.php.orig \ + wp-config.php.save \ + wp-config.php~ \ + .wp-config.php.swp \ + dump.sql \ + wp-content/uploads/dump.sql \ + wp-content/uploads/wordpress.tar.gz \ + wp-content/uploads/site.tgz \ + wp-content/uploads/site.7z \ + wp-content/uploads/site.rar \ + wp-content/debug.log \ + wp-content/uploads/wc-logs/stripe.log \ + wp-content/uploads/wc-logs/stripe.txt \ + wp-content/uploads/woocommerce_uploads/product.pdf \ + wp-content/updraft/backup.txt \ + wp-content/ai1wm-backups/site.wpress \ + wp-content/aiowps_backups/backup.txt \ + wp-content/backups-2025/site.txt \ + wp-content/upgrade/package.txt \ + wp-content/plugins/plugin/plugin.zip \ + backup.zip \ + wp-content/uploads/backup.zip \ + deploy.sh \ + .env \ + .git/config \ + wp-content/uploads/wc-logs/.htaccess \ + wp-content/plugins/plugin/composer.json \ + wp-content/plugins/plugin/composer.lock \ + wp-content/plugins/plugin/package.json \ + wp-content/plugins/plugin/package-lock.json \ + wp-content/plugins/plugin/phpunit.xml \ + wp-content/plugins/plugin/phpunit.xml.dist \ + wp-content/uploads/2024/12/attachment.zip \ + wp-content/uploads/2024/12/photo.png \ + wp-content/plugins/plugin/style.css \ + .well-known/acme-challenge/token; do + nginx_hardening_document_root_file "${path}" "${secret}" + done + nginx_hardening_integration_start + nginx_hardening_config_is_valid +} + +teardown() { + nginx_hardening_integration_stop +} + +assert_denied() { + local path + for path in "$@"; do + run nginx_hardening_request GET "${path}" + [ "${status}" -eq 0 ] + if [ "${output}" != "403" ] || grep -q "${secret}" "${nginx_hardening_tmp}/response-body"; then + echo "Expected ${path} to be denied, got HTTP ${output}" >&2 + return 1 + fi + done +} + +assert_served() { + local path + for path in "$@"; do + run nginx_hardening_request GET "${path}" + [ "${status}" -eq 0 ] + if [ "${output}" != "200" ] || ! grep -q "${secret}" "${nginx_hardening_tmp}/response-body"; then + echo "Expected ${path} to be served, got HTTP ${output}" >&2 + return 1 + fi + done +} + +@test "configuration backups and editor files are denied" { + assert_denied /wp-config.php.bak /wp-config.php.old /wp-config.php.orig /wp-config.php.save /wp-config.php~ /.wp-config.php.swp +} + +@test "database dumps and archives are denied" { + assert_denied /dump.sql /wp-content/uploads/dump.sql /wp-content/uploads/wordpress.tar.gz /wp-content/uploads/site.tgz /wp-content/uploads/site.7z /wp-content/uploads/site.rar +} + +@test "logs are denied even with an htaccess next to them" { + assert_denied /wp-content/debug.log /wp-content/uploads/wc-logs/stripe.log /wp-content/uploads/wc-logs/stripe.txt /wp-content/uploads/wc-logs/.htaccess +} + +@test "backup plugin and WordPress working folders are denied" { + assert_denied /wp-content/updraft/backup.txt /wp-content/ai1wm-backups/site.wpress /wp-content/aiowps_backups/backup.txt /wp-content/backups-2025/site.txt /wp-content/upgrade/package.txt /wp-content/uploads/woocommerce_uploads/product.pdf +} + +@test "zip files outside dated uploads are denied" { + assert_denied /backup.zip /wp-content/uploads/backup.zip /wp-content/plugins/plugin/plugin.zip +} + +@test "shell scripts and dotfiles are denied" { + assert_denied /deploy.sh /.env /.git/config +} + +@test "dependency manifests and test configuration are denied" { + assert_denied /wp-content/plugins/plugin/composer.json /wp-content/plugins/plugin/composer.lock /wp-content/plugins/plugin/package.json /wp-content/plugins/plugin/package-lock.json /wp-content/plugins/plugin/phpunit.xml /wp-content/plugins/plugin/phpunit.xml.dist +} + +@test "media, assets and ACME challenges are still served" { + assert_served /wp-content/uploads/2024/12/attachment.zip /wp-content/uploads/2024/12/photo.png /wp-content/plugins/plugin/style.css /.well-known/acme-challenge/token +}