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