Unless @beneverard has a reason not to, I intend to proceed with this.
(formerly iThemes Security Pro)
Why
iThemes Security Pro adds a flat ~0.35 s to every wp-admin request. Measured locally with OPcache on, best of five requests, one plugin skipped per request:
| State |
Dashboard |
Post list |
| Plugin not loaded |
0.351 s |
1.064 s |
| Plugin loaded, 0 modules enabled |
0.685 s |
— |
| Plugin loaded, 10 of 13 modules enabled |
0.675 s |
1.416 s |
Wordfence on coalition, for comparison |
0.082 s |
0.059 s |
The cost is the plugin's core bootstrap, not its features: going from no modules to ten adds −0.010 s, i.e. noise. Tuning modules cannot recover this time. The cost is also admin-only —
the front end pays ~0.09 s or less, and a post save 0.06 s.
The ten exclude ssl, system-tweaks and firewall, which have to be off for the plugin to run against a local http site. Their cost was not isolated, but since ten modules cost nothing over no modules, there is no reason to expect three more to differ.
For context, on the post list the whole plugin stack costs 0.95 s, of which WPML and ACF are 0.79 s. This change addresses a third of the problem; WPML and ACF are the other two thirds.
What changes
| Capability |
After the switch |
| Login brute force |
Unchanged — Apache basic auth on wp-login.php stops it before PHP |
| 2FA / passkeys |
Improved — iThemes has zero enrolled users; Wordfence Login Security is in real use on coalition |
| XML-RPC blocked |
Preserved, by step 1 below instead of by the plugin |
| PHP execution blocked in uploads / plugins / themes |
Preserved, by step 1 below instead of by the plugin |
| Malware and core file scanning |
Preserved |
| │ Malware scanning of plugin/theme files │ Preserved — scansEnabled_malware and scansEnabled_fileContents cover the whole tree regardless │ |
|
| Plugin / theme files compared to wordpress.org originals |
New, via step 2 — but only for the ~15 plugins that are in the repository |
| IP and user-agent blocklists |
Dropped — do this at Cloudflare, which already fronts the site |
| 404 detection, network ban lists, geolocation |
Dropped — redundant behind Cloudflare and basic auth |
| Password expiry |
No change in practice: it was never enforced (module inactive, requirement group empty) |
| Version management |
No change in practice: every switch is off; it only recorded first_seen timestamps |
| User action audit trail |
Lost. We can either accept the loss or install a replacement (Simple History, WP Activity Log). I vote accept the loss. |
| Update frequency of firewall rules and malware signatures |
Wordfence is on the free plan, which receives these on a 30-day delay. We can choose to pay for a license |
Note that iThemes File Change Detection is active but has never completed a scan on this server: last_run 2018-05-16, last_scan 0, stalled at chunk 2, and its expected_hashes are keyed to /home/opencontracting/… — a path from some server migrations ago (global.log_location is stale the same way). The exclude list still names pb_backupbuddy and a tweets.json. It's holding 13 MB in ocp_itsec_distributed_storage, which is harmless in itself — it's read only during a scan, never on a page load.
Notes
Reminder: Do not change Wordfence's IP source. salt/apache/cloudflare.sls already enables mod_remoteip with RemoteIPHeader CF-Connecting-IP and Cloudflare's ranges as trusted proxies, so REMOTE_ADDR is the real visitor IP. Wordfence's default howGetIPs = REMOTE_ADDR is correct. Pointing it at the raw header instead would trust a spoofable value.
Step 1 — deploy repo: move hardening into Apache
Details
The plugin currently writes an 867-line block into public_html/.htaccess between # BEGIN Kadence Security and # END Kadence Security (mostly redundant with Cloudflare). Two of those rules are real protections that Apache does not otherwise provide.
Wordfence's does have a allow-xml-rpc setting, but Apache is better.
Edit salt/apache/files/sites/wordpress.conf.include:
<FilesMatch "^wp-login\.php">
AuthType Basic
AuthName "Restricted Area"
AuthUserFile /etc/apache2/.htpasswd-{{ user }}
Require valid-user
</FilesMatch>
+
+<FilesMatch "^xmlrpc\.php$">
+ Require all denied
+</FilesMatch>
+
+<DirectoryMatch "^/home/{{ user }}/public_html/wp-content/(uploads|plugins|themes)/">
+ <FilesMatch "\.(?:php[1-7]?|pht|phtml?|phps)\.?$">
+ Require all denied
+ </FilesMatch>
+</DirectoryMatch>
To test, e.g. on www.open-spending.eu, write a file in uploads like:
<?php echo "PHP-EXECUTED";
Verification
B=https://www.open-contracting.org
curl -sS -o /dev/null -w '%{http_code} xmlrpc\n' $B/xmlrpc.php # 403
curl -sS -o /dev/null -w '%{http_code} wp-login\n' $B/wp-login.php # 401
curl -sS -o /dev/null -w '%{http_code} w3tc-sns\n' $B/wp-content/plugins/w3-total-cache/pub/sns.php # 403
curl -sS -o /dev/null -w '%{http_code} gf-preview\n' $B/wp-content/plugins/gravityforms/preview.php # 403
curl -sS -o /dev/null -w '%{http_code} theme-fns\n' $B/wp-content/themes/ocp-v1/functions.php # 403
curl -sS -o /dev/null -w '%{http_code} home\n' $B/ # 200
# GF's real routes must still work (they go through index.php, not the files above)
wp eval 'echo add_query_arg("gf_page","preview",home_url("/")), "\n";'
# Front end healthy
curl -sS -o /dev/null -w '%{http_code} %{time_total}\n' $B/
B=https://www.open-spending.eu
curl -sS -o /dev/null -w '%{http_code} xmlrpc\n' $B/xmlrpc.php # 403
curl -sS -o /dev/null -w '%{http_code} wp-login\n' $B/wp-login.php # 401
curl -sS -o /dev/null -w '%{http_code} contact\n' $B/contact-us/ # 200
curl -sS -o /dev/null -w '%{http_code} home\n' $B/ # 200
# The plugin ships PHP under wp-content; confirm the new rules don't break cache purging.
curl -sS -o /dev/null -w '%{http_code} plugin-php\n' $B/wp-content/plugins/wp-cloudflare-page-cache/index.php # 403 ok
curl -sS -D - -o /dev/null $B/ | grep -iE "cf-cache-status|cache-control" # HIT + s-maxage intact
# then purge from the plugin's admin screen and confirm a subsequent request MISSes, then HITs again
# Front end healthy
curl -sS -o /dev/null -w '%{http_code} %{time_total}\n' $B/
Upload a harmless .php file to wp-content/uploads/ and confirm it returns 403 rather than executing, then delete it.
Can also test the /contact-office-hours/ and /contact-us/ forms.
Step 2 — install and configure Wordfence
Install Wordfence, then set it to match coalition. Via WP-CLI on the server (cd ~/public_html):
wp plugin install wordfence --activate
# Match coalition: live traffic logging off, it is the expensive feature.
wp eval 'wfConfig::set("liveTrafficEnabled", 0);'
wp eval 'wfConfig::set("firewallEnabled", 1);'
wp eval 'wfConfig::set("loginSecurityEnabled", 1);'
wp eval 'wfConfig::set("autoBlockScanners", 1);'
wp eval 'wfConfig::set("blockedTime", 300);'
wp eval 'wfConfig::set("scansEnabled_plugins", 1);'
wp eval 'wfConfig::set("scansEnabled_themes", 1);'
# Leave howGetIPs at REMOTE_ADDR — mod_remoteip already restores the visitor IP.
wp eval 'echo wfConfig::get("howGetIPs"), "\n";' # expect: REMOTE_ADDR (or empty, which is the same default)
wp eval 'wfConfig::set("alertEmails", "sysadmin@open-contracting.org");'
Then in Wordfence → Login Security → Settings, require 2FA for Administrators and set a grace period, matching coalition (required-2fa-role.administrator, 2fa-user-grace-period = 10).
Step 3 — enrol administrators in 2FA
Users reach wp-login.php through the Apache basic auth prompt first, then WordPress login, then the 2FA challenge. Warn admins about the extra step.
Step 4 — remove iThemes Security Pro
wp plugin deactivate ithemes-security-pro
wp plugin delete ithemes-security-pro
Then check things it leaves behind:
-
.htaccess — the plugin should remove its own block. Confirm, and delete manually if not:
grep -c "Kadence Security" ~/public_html/.htaccess # expect 0
Leave the # BEGIN W3TC and # BEGIN WordPress blocks alone.
-
Database leftovers: the itsec_* tables and itsec-storage / itsec_active_modules options. They are already excluded from snapshots by script/snapshot-site, and can be dropped once confident in the switch.
Step 5 — verification
Log in and time a few admin screens. Expect roughly 0.3 s off every one of them; the dashboard and post list are the clearest comparisons against the table at the top of this document.
Confirm in Wordfence that a scan runs and reports clean, and that the scan now covers plugins and themes. To start one:
wp eval 'wfScanEngine::startScan();'
Checklist
Rollback
Reactivate iThemes Security Pro. It rewrites its .htaccess block and FORCE_SSL_ADMIN on activation, restoring the previous state. The Apache rules from step 1 stay — they duplicate the plugin's rules rather than conflicting with them, so there is nothing to undo in the deploy repo. Users enrolled in Wordfence 2FA keep their enrolment; it is unused while iThemes is active.
Unless @beneverard has a reason not to, I intend to proceed with this.
(formerly iThemes Security Pro)
Why
iThemes Security Pro adds a flat ~0.35 s to every wp-admin request. Measured locally with OPcache on, best of five requests, one plugin skipped per request:
coalition, for comparisonThe cost is the plugin's core bootstrap, not its features: going from no modules to ten adds −0.010 s, i.e. noise. Tuning modules cannot recover this time. The cost is also admin-only —
the front end pays ~0.09 s or less, and a post save 0.06 s.
The ten exclude
ssl,system-tweaksandfirewall, which have to be off for the plugin to run against a local http site. Their cost was not isolated, but since ten modules cost nothing over no modules, there is no reason to expect three more to differ.For context, on the post list the whole plugin stack costs 0.95 s, of which WPML and ACF are 0.79 s. This change addresses a third of the problem; WPML and ACF are the other two thirds.
What changes
wp-login.phpstops it before PHPcoalitionfirst_seentimestampsNote that iThemes File Change Detection is active but has never completed a scan on this server:
last_run2018-05-16,last_scan0, stalled at chunk 2, and itsexpected_hashesare keyed to/home/opencontracting/…— a path from some server migrations ago (global.log_location is stale the same way). The exclude list still names pb_backupbuddy and a tweets.json. It's holding 13 MB in ocp_itsec_distributed_storage, which is harmless in itself — it's read only during a scan, never on a page load.Notes
Reminder: Do not change Wordfence's IP source.
salt/apache/cloudflare.slsalready enablesmod_remoteipwithRemoteIPHeader CF-Connecting-IPand Cloudflare's ranges as trusted proxies, soREMOTE_ADDRis the real visitor IP. Wordfence's defaulthowGetIPs = REMOTE_ADDRis correct. Pointing it at the raw header instead would trust a spoofable value.Step 1 — deploy repo: move hardening into Apache
Details
The plugin currently writes an 867-line block into
public_html/.htaccessbetween# BEGIN Kadence Securityand# END Kadence Security(mostly redundant with Cloudflare). Two of those rules are real protections that Apache does not otherwise provide.Wordfence's does have a
allow-xml-rpcsetting, but Apache is better.Edit
salt/apache/files/sites/wordpress.conf.include:To test, e.g. on www.open-spending.eu, write a file in uploads like:
Verification
Upload a harmless
.phpfile towp-content/uploads/and confirm it returns 403 rather than executing, then delete it.Can also test the
/contact-office-hours/and/contact-us/forms.Step 2 — install and configure Wordfence
Install Wordfence, then set it to match
coalition. Via WP-CLI on the server (cd ~/public_html):Then in Wordfence → Login Security → Settings, require 2FA for Administrators and set a grace period, matching
coalition(required-2fa-role.administrator,2fa-user-grace-period = 10).Step 3 — enrol administrators in 2FA
Users reach
wp-login.phpthrough the Apache basic auth prompt first, then WordPress login, then the 2FA challenge. Warn admins about the extra step.Step 4 — remove iThemes Security Pro
Then check things it leaves behind:
.htaccess— the plugin should remove its own block. Confirm, and delete manually if not:Leave the
# BEGIN W3TCand# BEGIN WordPressblocks alone.Database leftovers: the
itsec_*tables anditsec-storage/itsec_active_modulesoptions. They are already excluded from snapshots byscript/snapshot-site, and can be dropped once confident in the switch.Step 5 — verification
Log in and time a few admin screens. Expect roughly 0.3 s off every one of them; the dashboard and post list are the clearest comparisons against the table at the top of this document.
Confirm in Wordfence that a scan runs and reports clean, and that the scan now covers plugins and themes. To start one:
Checklist
Rollback
Reactivate iThemes Security Pro. It rewrites its
.htaccessblock andFORCE_SSL_ADMINon activation, restoring the previous state. The Apache rules from step 1 stay — they duplicate the plugin's rules rather than conflicting with them, so there is nothing to undo in the deploy repo. Users enrolled in Wordfence 2FA keep their enrolment; it is unused while iThemes is active.