diff --git a/nginx/conf.d/spx-bot-mitigation-logic.conf b/nginx/conf.d/spx-bot-mitigation-logic.conf index 1c5863b..1d3ce01 100644 --- a/nginx/conf.d/spx-bot-mitigation-logic.conf +++ b/nginx/conf.d/spx-bot-mitigation-logic.conf @@ -61,3 +61,12 @@ map "$ua_block_type:$empty_ua_geo_block" $block_reason { "1:1" "malformed_or_scanner"; default ""; } + +# 6. Hard-block subset used by upload handlers. +# Upload routes may bypass soft UA/geo-only signals to reduce false positives, +# but must still enforce hard security blocks (scanner/header-injection/legacy UA). +map $block_reason $hard_block_reason { + default $block_reason; + "empty_user_agent" ""; + "empty_ua_high_risk_geo" ""; +} diff --git a/nginx/snippets/spx-upload-limits.conf b/nginx/snippets/spx-upload-limits.conf index 0718c5a..0370a08 100644 --- a/nginx/snippets/spx-upload-limits.conf +++ b/nginx/snippets/spx-upload-limits.conf @@ -2,6 +2,12 @@ # Shared WordPress upload route overrides # Keep this snippet included in every HTTPS WordPress vhost server block. # ----------------------------------------------------------------------------- +# +# NOTE: These upload handlers bypass soft $block_reason UA/geo checks to avoid +# false-positive 403s on legitimate upload clients, but still enforce +# $hard_block_reason hard security blocks. Method restrictions and rate limits +# still apply in these handlers, while upload authorization is enforced by +# WordPress upstream. # WordPress plugin/theme ZIP uploader — raised body limit for this single # handler only. update.php processes plugin and theme uploads submitted via @@ -10,8 +16,6 @@ # than on the broad /wp-admin/ prefix so all other admin routes stay at the # safe 10m global default. location = /wp-admin/update.php { - if ($block_reason != "") { return 403; } - # 64m covers the vast majority of plugin/theme archives while remaining # well below the 100m cap used for media uploads. client_max_body_size 64m; @@ -25,6 +29,7 @@ location = /wp-admin/update.php { deny all; } + if ($hard_block_reason != "") { return 403; } limit_req zone=spx_wp_admin burst=150 nodelay; include /etc/nginx/snippets/spx-standard-proxy-headers.conf; include /etc/nginx/snippets/spx-dynamic-proxy-headers.conf; @@ -33,8 +38,6 @@ location = /wp-admin/update.php { # WP admin async upload — POST only; uses wp_admin rate zone. location = /wp-admin/async-upload.php { - if ($block_reason != "") { return 403; } - client_max_body_size 100m; if ($request_method = OPTIONS) { @@ -46,6 +49,7 @@ location = /wp-admin/async-upload.php { deny all; } + if ($hard_block_reason != "") { return 403; } limit_req zone=spx_wp_admin burst=150 nodelay; include /etc/nginx/snippets/spx-standard-proxy-headers.conf; include /etc/nginx/snippets/spx-dynamic-proxy-headers.conf; @@ -53,9 +57,9 @@ location = /wp-admin/async-upload.php { } # WP REST API media — GET/HEAD for reads, POST for uploads, OPTIONS for CORS. -location ~* ^/wp-json/wp/v2/media { - if ($block_reason != "") { return 403; } - +# Pattern matches only the collection endpoint and numeric single-item paths +# (/wp-json/wp/v2/media and /wp-json/wp/v2/media/{id}); nothing broader. +location ~* ^/wp-json/wp/v2/media(/[0-9]+)?$ { client_max_body_size 100m; if ($request_method = OPTIONS) { @@ -67,6 +71,7 @@ location ~* ^/wp-json/wp/v2/media { deny all; } + if ($hard_block_reason != "") { return 403; } limit_req zone=spx_general burst=200 nodelay; include /etc/nginx/snippets/spx-standard-proxy-headers.conf; include /etc/nginx/snippets/spx-dynamic-proxy-headers.conf; diff --git a/var/www/html/.user.ini b/var/www/html/.user.ini new file mode 100644 index 0000000..03a0a78 --- /dev/null +++ b/var/www/html/.user.ini @@ -0,0 +1,33 @@ +; ============================================================================= +; PHP-FPM per-directory upload size overrides +; Deployed to: /var/www/html/.user.ini +; +; PHP-FPM scans for .user.ini files in the directory containing each requested +; PHP file and all parent directories up to the document root, and caches the +; result for user_ini.cache_ttl seconds (default typically 300). Settings placed +; here apply to every PHP request served from /var/www/html/. +; +; NOTE: upload_max_filesize and post_max_size are PHP_INI_PERDIR entries that +; must be configured via php.ini or an FPM pool .conf (not via ini_set()). +; Web access to this file is blocked by the Nginx dotfile deny (location ~ /\. { deny all; }) +; and by Apache common-settings.conf ( Require all denied). +; +; These values are aligned with the nginx per-route body-size limits: +; /wp-admin/update.php → client_max_body_size 64m +; /wp-admin/async-upload.php → client_max_body_size 100m +; /wp-json/wp/v2/media → client_max_body_size 100m +; +; upload_max_filesize: individual file cap — set below the 100m Nginx limit so a +; multipart/form-data request (file + boundaries + fields) stays under the +; upstream request-size cap. +; +; post_max_size: total POST body cap — must exceed upload_max_filesize to leave +; room for multipart boundary and form fields. 100M provides ~5M headroom above +; the 95M file cap. +; +; Cloudflare hard cap: ~100 MB per request (plan-dependent). The effective limit +; is still capped upstream. +; ============================================================================= + +upload_max_filesize = 95M +post_max_size = 100M