diff --git a/scripts/test-wpadmin-gate.py b/scripts/test-wpadmin-gate.py index 8cef3e4..a00df20 100644 --- a/scripts/test-wpadmin-gate.py +++ b/scripts/test-wpadmin-gate.py @@ -106,6 +106,49 @@ class WpAdminGate(unittest.TestCase): def test_only_one_has_wp_logged_in_declaration(self): self.assertEqual(self.cfg.count('acl has_wp_logged_in'), 1) + def test_allowlist_entries_are_anchored_to_wp_admin(self): + """Bare `path_end /admin-ajax.php` also matches + /wp-admin/evil/admin-ajax.php, which ALSO matches wp_admin_path + (path_reg only requires /wp-admin/ to appear somewhere) -- an + attacker-inserted path segment would then sail through the + allowlist ungated. Entries must be anchored to sit directly under + wp-admin/. Scoped to the captured ACL line only, since the + surrounding comment block also mentions these bare filenames. + """ + m = re.search(r'acl\s+wp_admin_allowed\s+path_end([^\n]*)', self.cfg) + self.assertIsNotNone(m, 'wp_admin_allowed ACL not found') + line = m.group(1) + for entry in ALLOWLIST: + with self.subTest(entry=entry): + self.assertIn('/wp-admin' + entry, line) + self.assertNotRegex( + line, r'(? /wp-login.php and + # /blog/wp-admin/plugins.php -> /blog/wp-login.php, both with + # redirect_to preserved. See + # .superpowers/sdd/2026-08-14-wpadmin-edge-gate/task-3b-report.md. + # # redirect_to carries the path only (%[path,url_enc]), not the query # string -- deliberate, see design spec. An admin bounced off # post.php?post=123&action=edit lands back on a blank post.php rather @@ -238,6 +270,16 @@ frontend web # wp-login.php itself still returns 200, making it a silent regression # that "looks like" the gate is working. # + # Each allowlist entry is anchored to /wp-admin/, not a bare + # filename suffix. A bare `path_end /admin-ajax.php` also matches + # /wp-admin/evil/admin-ajax.php -- which ALSO matches wp_admin_path + # (path_reg only requires /wp-admin/ to appear somewhere), so an + # attacker-inserted path segment would sail through this allowlist + # ungated and boot full WordPress, exactly the resource exhaustion this + # gate exists to stop. Anchoring still covers subdirectory installs via + # suffix matching (/blog/wp-admin/admin-ajax.php ends with + # /wp-admin/admin-ajax.php) while rejecting an inserted directory. + # # install.php is DELIBERATELY NOT allowlisted. It is legitimately # reachable without a cookie during a fresh install, but it is also a # standing scanner target and a real takeover vector on a site that was @@ -250,10 +292,11 @@ frontend web # empty by start-up.sh) for sites where a plugin legitimately serves # unauthenticated visitors from a /wp-admin/ URL outside this allowlist. acl wp_admin_path path_reg (^|/)wp-admin/ - acl wp_admin_allowed path_end /admin-ajax.php /admin-post.php /load-styles.php /load-scripts.php + acl wp_admin_allowed path_end /wp-admin/admin-ajax.php /wp-admin/admin-post.php /wp-admin/load-styles.php /wp-admin/load-scripts.php acl wp_admin_asset path_reg (^|/)wp-admin/(css|js|images)/ acl wp_gate_exempt hdr(host),lower -f /etc/haproxy/wpadmin_gate_exempt.list - http-request redirect code 302 location %[path,regsub((^|/)wp-admin/.*,\1wp-login.php)]?redirect_to=%[path,url_enc] if wp_admin_path !wp_admin_allowed !wp_admin_asset !has_wp_logged_in !wp_gate_exempt !is_local !is_trusted_ip !is_whitelisted + http-request set-var(txn.wp_login_url) path,regsub(/wp-admin/.*,/wp-login.php) if wp_admin_path + http-request redirect code 302 location %[var(txn.wp_login_url)]?redirect_to=%[path,url_enc] if wp_admin_path !wp_admin_allowed !wp_admin_asset !has_wp_logged_in !wp_gate_exempt !is_local !is_trusted_ip !is_whitelisted # IP blocking using map file (manual blocks only) # Map file format: /etc/haproxy/blocked_ips.map contains " 1" per line