From 8b149a672d060fce5dbe42517247adc835bb84e8 Mon Sep 17 00:00:00 2001 From: Twooey Date: Fri, 28 Aug 2026 11:15:55 -0400 Subject: [PATCH] Restrict production wp-admin/wp-login to an IP allowlist (opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New WP_ADMIN_ALLOWED_IPS env var (space-separated CIDR ranges, matching Caddy's remote_ip syntax directly). Defaults to 0.0.0.0/0 ::/0 (today's open behavior) when unset, resolved at the Compose level rather than in the Caddyfile so an unset var can never leave `remote_ip` with zero arguments and fail Caddy's startup. admin-ajax.php stays exempt since WooCommerce routes logged-out storefront AJAX through it. Production only — staging is already LAN-only. Verified live: a scratch Caddy instance on a Docker network, with a sibling container's actual observed source IP used to prove both the blocked (403 on wp-admin/wp-login, everything else unaffected) and allowed (matching CIDR lets it through) cases, plus `docker compose config` to confirm the unset-var default resolves correctly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MqhgnCRjjVLFnZ3Jpn93Vg --- .env.example | 7 +++++++ README.md | 13 +++++++++++++ docker-compose.production.yml | 6 ++++++ docker/caddy/Caddyfile.production | 13 +++++++++++++ 4 files changed, 39 insertions(+) diff --git a/.env.example b/.env.example index fb1a198..df47259 100644 --- a/.env.example +++ b/.env.example @@ -61,3 +61,10 @@ MAILERLITE_API_KEY= # --- Hardcover (AO3-style tag browsing — genre/mood/content-warning/tag sync) --- # Get a token: hardcover.app account settings -> Hardcover API -> New API Key HARDCOVER_API_TOKEN= + +# --- Admin access restriction (production only) --- +# Space-separated IPs/CIDR ranges allowed to reach /wp-admin and +# /wp-login.php (matches Caddy's `remote_ip` syntax directly — no quotes, +# space-separated even though most other values here are one token). +# Leave blank to leave wp-admin reachable from anywhere (today's behavior). +WP_ADMIN_ALLOWED_IPS= diff --git a/README.md b/README.md index 8510a91..ef8a7c0 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,19 @@ is blocked on it. Go-live (switching the gateway from sandbox to Helcim's live environment) is a deliberate manual step on the settings screen, not something this repo automates. +### Admin access restriction (production) + +`/wp-admin` and `/wp-login.php` can be restricted to specific IPs/CIDR +ranges in production via `WP_ADMIN_ALLOWED_IPS` in `.env.production` — +space-separated, matching Caddy's own `remote_ip` syntax directly (e.g. +`203.0.113.5 198.51.100.0/24`). Leave it blank and wp-admin stays +reachable from anywhere, same as today — this is opt-in, not on by +default, and only applies in production (staging is already LAN-only). +`/wp-admin/admin-ajax.php` is always exempt regardless of the IP list, +since WordPress/WooCommerce route some logged-out storefront AJAX +through it. Applying a changed value just needs `make deploy ENV=production` +(or restarting the `caddy` container) — no code change required. + ## Deploy pipeline The Git server (Gitea) and the staging/production Docker hosts are separate diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 5b4178e..85d1c4d 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -19,6 +19,12 @@ services: - "443:443" environment: SITE_DOMAIN: ${SITE_DOMAIN} + # Default keeps wp-admin open to everyone (today's behavior) when + # unset — resolved here, not in the Caddyfile, since Compose would + # otherwise pass an *empty* string rather than an unset var, and + # Caddy's own {$VAR:default} placeholder only applies to a var + # that's truly absent, not an empty one. + WP_ADMIN_ALLOWED_IPS: ${WP_ADMIN_ALLOWED_IPS:-0.0.0.0/0 ::/0} volumes: - ./docker/caddy/Caddyfile.production:/etc/caddy/Caddyfile:ro - wp_core:/var/www/html:ro diff --git a/docker/caddy/Caddyfile.production b/docker/caddy/Caddyfile.production index 9af403c..e1b6104 100644 --- a/docker/caddy/Caddyfile.production +++ b/docker/caddy/Caddyfile.production @@ -18,6 +18,19 @@ @dev_only path /wp-content/plugins/bookstore-core/tests/* /wp-content/plugins/bookstore-core/vendor/* /wp-content/plugins/bookstore-core/composer.json /wp-content/plugins/bookstore-core/composer.lock /wp-content/plugins/bookstore-core/phpunit.xml* respond @dev_only 404 + # WP_ADMIN_ALLOWED_IPS (.env.production) — defaults to 0.0.0.0/0 ::/0 + # (unrestricted) in docker-compose.production.yml when unset. Excludes + # admin-ajax.php: WordPress/WooCommerce route logged-out-visitor AJAX + # (cart fragments, nonces, some add-to-cart flows) through that file + # even though it lives under /wp-admin/ — blocking it by IP would break + # the storefront for every visitor, not just admin access. + @blocked_admin { + path /wp-admin* /wp-login.php + not path /wp-admin/admin-ajax.php + not remote_ip {$WP_ADMIN_ALLOWED_IPS} + } + respond @blocked_admin 403 + php_fastcgi wordpress:9000 file_server