Skip to content

Apache: A <Location "/"> Grant Silently Overrides <Files> Rules (and How to Lock xmlrpc.php to Jetpack)

The Problem

A WordPress site was taking thousands of POST /xmlrpc.php requests a day from random IPs, some with fake Jetpack by WordPress.com user agents. Every one got 200. Nothing on the site publishes over XML-RPC except Jetpack, so the plan was the standard one: allow xmlrpc.php only from Jetpack's IP ranges and refuse everyone else.

The textbook block was deployed server-wide in conf-enabled/:

<Files "xmlrpc.php">
    Require ip 192.0.64.0/18
</Files>

apachectl configtest said Syntax OK, the graceful reload succeeded, and nothing changed. A test POST from an outside IP still got 200 and the full method list.

Why It Happens: Section Merge Order

Apache doesn't apply config sections in file order. It merges them in a fixed sequence, and later groups override earlier ones:

  1. <Directory> (non-regex) and .htaccess
  2. <DirectoryMatch>
  3. <Files> and <FilesMatch>
  4. <Location> and <LocationMatch>
  5. <If>

Within each group, sections apply in the order they appear (and virtual-host sections after main-server ones).

The server had another conf, an AI-crawler blocklist, that did this:

<Location "/">
    <RequireAll>
        Require all granted
        Require not env ai_bot
    </RequireAll>
</Location>

<Location "/"> matches every request, and it merges after <Files>. So its Require all granted replaced the <Files "xmlrpc.php"> rule. Apache doesn't warn about this.

[!warning] Any blanket <Location "/"> with a Require in it overrides every <Files>, <FilesMatch> and <Directory> access rule on the server, including the stock ones. On Debian/Ubuntu that includes apache2.conf's <FilesMatch "^\.ht"> Require all denied, so .htaccess becomes publicly readable. It also overrides the PHP-FPM conf's denies for .phps and similar files.

How to Tell If You're Affected

# Should be 403 on any stock Debian/Ubuntu Apache. 200 means something is overriding <FilesMatch "^\.ht">.
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/.htaccess

# Find blanket Location grants
grep -rnE '<Location(Match)? +"?/"?>' -A6 /etc/apache2/ | grep -iE 'Location|Require'

# Confirm your conf is actually included (and in what order)
apachectl -t -D DUMP_INCLUDES | grep conf-enabled

The Fix for xmlrpc.php

Put the rule in a <LocationMatch> so it lands in the same merge group as the blanket grant. Then make sure it comes after that grant: name the conf so it sorts later in conf-enabled/ (e.g. local-xmlrpc-lock.conf sorts after block-ai-crawlers.conf).

# /etc/apache2/conf-available/local-xmlrpc-lock.conf
# xmlrpc.php: Jetpack (WordPress.com) and localhost only; everyone else 403.
<LocationMatch "/xmlrpc\.php$">
    Require ip 127.0.0.1 122.248.245.244 54.217.201.243 54.232.116.4 192.0.64.0/18 195.234.108.0/22
</LocationMatch>
sudo a2enconf local-xmlrpc-lock
sudo apachectl configtest && sudo systemctl reload apache2

Notes on this rule:

  • The regex catches //xmlrpc.php too. Scanners often send the double-slash form, and Location matching collapses repeated slashes (MergeSlashes On is the default).
  • The request is refused before PHP runs, so the flood no longer ties up PHP-FPM workers.
  • Use Jetpack's full list, not just 192.0.64.0/18. The current list is at https://jetpack.com/ips-v4.txt. As of 2026-10-20 it has eight entries; three are /20s inside the /18, and 195.234.108.0/22 plus three single addresses are outside it. Jetpack says WordPress.com connects over IPv4 only, so there's no IPv6 list.
  • Keep 127.0.0.1 so the site's own loopback calls still work.
  • Anything else that uses XML-RPC stops working: the WordPress mobile app, desktop blogging tools, pingbacks. Add their IPs if you need them.

The Root Fix: Make the Site-Wide Block Deny-Only

The xmlrpc rule above works around the blanket grant. The better fix is to stop the AI-crawler conf from granting anything at all. Replace the <Location "/"> block with an <If> that only applies to tagged requests:

<IfModule mod_setenvif.c>
    SetEnvIfNoCase User-Agent "GPTBot"       ai_bot
    SetEnvIfNoCase User-Agent "ClaudeBot"    ai_bot
    # ...
</IfModule>

<If "reqenv('ai_bot') != ''">
    Require all denied
</If>

Tagged crawlers still get 403. Every other request falls through to the normal <Directory>/<Files>/.htaccess rules, which start working again. On the server where this was fixed, after the change:

  • /.htaccess and stale copies like /.htaccess_old → 403 (Apache's stock ^\.ht rule applies again)
  • normal pages, wp-login.php, the REST API, sitemap, robots.txt → 200
  • an AI user agent → 403, same as before
  • plugin .htaccess denies start applying. Akismet's, for example, refuses everything in its folder except its own scripts and images. Check your access log for assets the site actually loads from such folders before the change.

Verify

Use a harmless method call, never a fake login. Login-failure jails like wp-fail2ban's wordpress-hard can ban your IP on the first failure.

BODY='<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName><params></params></methodCall>'
for p in /xmlrpc.php //xmlrpc.php; do
  curl -s -o /dev/null -w "$p %{http_code} %{size_download}B\n" -X POST -H 'Content-Type: text/xml' --data "$BODY" "https://example.com$p"
done
# Expect 403 for both from outside. Before the fix this was 200 with a ~4 KB method list.

Then confirm Jetpack still gets in. wp jetpack status runs a test__wpcom_connection_test that makes WordPress.com call back into the site. It should pass, and the access log should show the callback from a Jetpack range with 200.

[!warning] Jetpack signs its requests in the query string (?for=jetpack&token=…&signature=…). When you check the access log, strip the query string before printing paths, or you'll copy the connection token into your terminal or notes:

awk '$7 ~ /xmlrpc/ {split($7,u,"?"); print $1, u[1], $9}' /var/log/apache2/access.log | tail

Key Notes

  • configtest passing and a clean reload prove nothing about access rules. Always test the actual response from an outside IP after changing a Require.
  • A blanket <Location "/"> grant is a trap for every <Files>/<Directory> rule on the server. Site-wide blocks should only deny (see the root fix above). Re-run the .htaccess check after any change to them.
  • Related: mod_evasive Can't Whitelist Dynamic Hostnames — Bridge It to Fail2ban Instead (another case where Jetpack's IP ranges need an exemption).