Hardening every WordPress site on cPanel in one loop
A field-tested bash loop that walks every cPanel user, finds every WordPress install, and applies an idempotent hardening checklist with weekly drift detection.
Hardening every WordPress site on cPanel in one loop
You manage twenty-seven WordPress sites on one cPanel server. A
clean hardening pass on a single site (disable xmlrpc, lock down
file editing, force SSL on the admin, security headers into
.htaccess, kill PHP execution in wp-content/uploads) is a
thirty-minute job. Twenty-seven of those is thirteen and a half
hours. You don't have thirteen and a half hours and you cannot
leave twenty-seven sites unhardened. This is the loop we use.
Script first, then the rationale. If you want to copy-paste, the next section is yours. If you want to know why each line is the way it is, keep going after that.
The script
#!/usr/bin/env bash
# /root/scripts/wp-harden-all.sh
# Walks every cPanel user, finds every wp-config.php, applies an
# idempotent hardening pass. Set DRY_RUN=1 to preview changes.
set -euo pipefail
DRY_RUN="${DRY_RUN:-0}"
LOG="/var/log/wp-harden/$(date +%Y-%m-%d).log"
mkdir -p "$(dirname "$LOG")"
log() { printf '%s %s\n' "$(date +%H:%M:%S)" "$*" | tee -a "$LOG"; }
apply() {
# apply <description> <file> <grep-test> <sed-or-cat-action>
local desc="$1" file="$2" test="$3" action="$4"
if grep -qE "$test" "$file"; then
return 0
fi
if [ "$DRY_RUN" = "1" ]; then
log "would-change: $file :: $desc"
else
eval "$action"
log "changed: $file :: $desc"
fi
}
harden_wpconfig() {
local cfg="$1"
apply "DISALLOW_FILE_EDIT=true" "$cfg" "DISALLOW_FILE_EDIT" \
"sed -i \"/^\\/\\* That's all, stop editing/i define('DISALLOW_FILE_EDIT', true);\" \"$cfg\""
apply "DISALLOW_FILE_MODS=true" "$cfg" "DISALLOW_FILE_MODS" \
"sed -i \"/^\\/\\* That's all, stop editing/i define('DISALLOW_FILE_MODS', true);\" \"$cfg\""
apply "FORCE_SSL_ADMIN=true" "$cfg" "FORCE_SSL_ADMIN" \
"sed -i \"/^\\/\\* That's all, stop editing/i define('FORCE_SSL_ADMIN', true);\" \"$cfg\""
apply "DISABLE_WP_CRON=true" "$cfg" "DISABLE_WP_CRON" \
"sed -i \"/^\\/\\* That's all, stop editing/i define('DISABLE_WP_CRON', true);\" \"$cfg\""
}
harden_htaccess() {
local root="$1" ht="$1/.htaccess"
[ -f "$ht" ] || { [ "$DRY_RUN" = "1" ] || : > "$ht"; }
apply "block xmlrpc.php" "$ht" "Files xmlrpc\\.php" \
"printf '%s\n' '<Files xmlrpc.php>' ' Require all denied' '</Files>' >> \"$ht\""
apply "X-Frame-Options" "$ht" "X-Frame-Options" \
"printf '%s\n' '<IfModule mod_headers.c>' ' Header set X-Frame-Options SAMEORIGIN' ' Header set X-Content-Type-Options nosniff' ' Header set Referrer-Policy strict-origin-when-cross-origin' '</IfModule>' >> \"$ht\""
apply "disable directory indexing" "$ht" "^Options.*-Indexes" \
"printf '%s\n' 'Options -Indexes' >> \"$ht\""
local up="$root/wp-content/uploads"
if [ -d "$up" ]; then
local upht="$up/.htaccess"
[ -f "$upht" ] || { [ "$DRY_RUN" = "1" ] || : > "$upht"; }
apply "no PHP in uploads" "$upht" "Files.*\\\\\\.ph" \
"printf '%s\n' '<FilesMatch \"\\.ph(p[3457]?|t|tml|ar)$\">' ' Require all denied' '</FilesMatch>' >> \"$upht\""
fi
}
is_multisite() {
grep -qE "MULTISITE.*true|SUBDOMAIN_INSTALL" "$1"
}
for home in /home/*/; do
user="$(basename "$home")"
id "$user" >/dev/null 2>&1 || continue
[ -f "$home/.skip-wp-harden" ] && { log "skip-flag: $user"; continue; }
while IFS= read -r -d '' cfg; do
root="$(dirname "$cfg")"
if is_multisite "$cfg"; then
log "multisite: $root (skipped, review manually)"
continue
fi
log "site: $root"
harden_wpconfig "$cfg"
harden_htaccess "$root"
done < <(find "$home" -xdev -name wp-config.php \
-not -path '*/backups/*' -not -path '*/.trash/*' -print0)
done
log "done"Three helpers, one outer loop. Run with DRY_RUN=1 first. Read
the log before running it for real.
Why a loop, not twenty-seven SSH sessions
A WordPress site is a stack of conventions. The conventions are
boring, repetitive, and almost identical across every install. At
fifteen-plus installs per server, hand-editing wp-config files
turns into a class of mistakes nobody admits to. The engineer
hardens twenty-three sites, gets pulled into a support ticket,
and four sites silently still allow plugin install from the admin
UI. Twenty-seven hand passes is also twenty-seven chances to typo
DISSALLOW_FILE_MODS, which raises no error and silently does
nothing because WordPress only reads the spelling it expects.
A loop fixes the labour problem and the consistency problem at once. Every site gets the same treatment, the same log line, a reviewable diff. Re-running it is free because each step is "if not present, add". A property called idempotency, which we'll come back to.
The hardening checklist
Nine small changes per site. Each closes a real category of compromise from our writeup of three real WordPress compromises.
DISALLOW_FILE_EDIT removes the in-admin file editor that lets
a logged-in administrator type PHP into the active theme. Once an
attacker has admin credentials, this constant is the difference
between "they can change content" and "they have a webshell".
DISALLOW_FILE_MODS prevents plugin or theme install from the
admin UI, breaking the "upload-a-zip-as-a-plugin" foothold that
vulnerable plugins use to implant arbitrary PHP.
FORCE_SSL_ADMIN makes every wp-admin request require HTTPS.
With AutoSSL or Let's Encrypt this is free; if it breaks something,
AutoSSL is failing and that itself is the bug. See
our AutoSSL postmortem.
DISABLE_WP_CRON removes the HTTP loopback that fires on every
front-end request. Both a hardening win and a stability win. The
full reasoning lives in
WordPress WP-Cron stacking on cPanel,
which also covers what to schedule in its place.
Block xmlrpc.php in .htaccess. xmlrpc is the brute-force
amplifier of choice. One HTTP request can attempt a thousand
password guesses via system.multicall. If a site genuinely needs
it for Jetpack mobile, the block becomes a narrow allow-list.
Security headers. X-Frame-Options: SAMEORIGIN,
X-Content-Type-Options: nosniff, and a sensible Referrer-Policy.
Inside an IfModule mod_headers.c guard so the file does not 500
on a build without the module.
Disable directory indexing. A surprising number of installs
leak listings from wp-content/uploads, wp-content/plugins, or
a forgotten _old folder. Options -Indexes returns 403.
Block PHP execution in wp-content/uploads. The single most
important rule on this list. WordPress legitimately lets users
upload to wp-content/uploads; it does not legitimately need to
execute PHP there. The FilesMatch block kills every variant
(.php, .php3, .php5, .phtml, .phar, .pht) and is the
one-line backstop that has saved more sites than any other change
in this post.
Idempotency and why it matters more than elegance
An idempotent script can run a hundred times and the result equals
running it once. The apply helper checks for the directive before
doing anything; if X-Frame-Options is already in the file, the
function returns. If not, it appends and logs changed:. There is
no "remove and re-add", no "replace if different". Once a directive
is present the script trusts what is there.
Idempotency matters because the script will run more than once.
Clients restore from backup. Migrations reset .htaccess. A
developer hand-edits wp-config.php to debug a plugin and removes
the constants. The weekly cron below is the reapplication loop,
and it is only safe to schedule because the script is idempotent.
The dry-run output
Before any first run on a server, set DRY_RUN=1:
DRY_RUN=1 /root/scripts/wp-harden-all.shThe log shows exactly what would change, file by file. A trimmed sample from a recent first-run on a fifteen-site server:
14:02:11 site: /home/harvestoa/public_html
14:02:11 would-change: /home/harvestoa/public_html/wp-config.php :: DISALLOW_FILE_EDIT=true
14:02:11 would-change: /home/harvestoa/public_html/wp-config.php :: DISALLOW_FILE_MODS=true
14:02:11 would-change: /home/harvestoa/public_html/.htaccess :: block xmlrpc.php
14:02:11 would-change: /home/harvestoa/public_html/.htaccess :: X-Frame-Options
14:02:12 would-change: /home/harvestoa/public_html/wp-content/uploads/.htaccess :: no PHP in uploads
14:02:12 site: /home/mossbroo/public_html
14:02:12 multisite: /home/mossbroo/public_html (skipped, review manually)
14:02:13 site: /home/ironbark/public_html
14:02:13 would-change: /home/ironbark/public_html/wp-config.php :: DISABLE_WP_CRON=true
14:02:13 would-change: /home/ironbark/public_html/.htaccess :: disable directory indexing
14:02:14 site: /home/pinewell/public_html
14:02:14 ... no would-change lines, site already hardened ...
14:02:15 doneThat output is the pre-flight checklist. Look for sites where the
script wants to make a surprising number of changes (those are
ones a previous engineer hand-tuned and deserve a manual look)
and confirm the multisite: lines are intentional skips.
When the dry run looks correct, run it for real, in batches: five sites at a time, verified live in a browser, then five more. Not on a Friday afternoon.
Edge cases the loop handles, and the ones it doesn't
Four edge cases handled. Subdirectory installs (common for
staging or example.com/blog/) are caught by the recursive
find. Backup snapshots are skipped via
-not -path '*/backups/*'. Multisite networks are detected
and skipped because the standard rules break subdomain routing.
An opt-out flag (/home/<user>/.skip-wp-harden) excludes a
single account without editing the script.
Three things it does not handle. It does not edit nginx configs;
cPanel boxes running nginx in front of Apache work because Apache
still reads .htaccess, but pure-nginx setups need a separate
runner. It does not manage wp-login.php IP allow-lists, because
admin IPs are per-agency policy. And it does not touch file
permissions; permission drift is a separate weekly job.
Keeping it applied
Run it once and the server is hardened today. Skip the cron and
the server slowly un-hardens: clients restore from backups, themes
regenerate .htaccess, security plugins overwrite directives they
don't recognise, migrations reset every constant. Drift is the
steady state. The cron that holds the line:
# /etc/cron.d/wp-harden: root, weekly, Sunday 02:30
30 2 * * 0 root /root/scripts/wp-harden-all.sh >> /var/log/wp-harden/cron.log 2>&1Weekly is the right cadence. Daily is loud and engineers stop reading the log; monthly is too slow because a Tuesday backup restore lives unhardened for three weeks. The log itself is the early warning:
grep "^changed:" /var/log/wp-harden/cron.log | awk '{print $4}' | sort | uniq -c | sort -rnA site that appears week after week has a backup-restore cadence fighting the hardening. That is a conversation with the client. A site that appears for the first time after months of being clean was either restored or attacked; either way, look at it.
When not to use this approach
Three categories of install where the loop is wrong.
Heavily customised installs where standard hardening breaks
features: a site that genuinely uses xmlrpc, a theme that expects
executable files in uploads, a plugin workflow built on the
in-admin uploader. Drop a .skip-wp-harden flag and document why.
Client-managed sites where the agency does not have authority
to change wp-config.php. If the client owns WordPress and you
only host it, the policy decision is theirs. Bulk-modifying their
config without consent is a contract violation even when it is
technically an improvement.
Multisite networks. Already handled by the skip rule, but worth restating: the hardening pattern for multisite is different and needs its own runbook.
How ServerGuard handles this
ServerGuard maps to this scenario in three places, and we want to be precise about which is which.
Per-site hardening sweep (upcoming work). SGuard's WordPress
hardening detector reads each install's wp-config.php and
.htaccess against the baseline this post describes and raises
an event when a site is missing a directive. The detector is on
the upcoming work; the product surfaces the gap as part of the per-server
WordPress audit but does not yet break it down per-install.
File-permission drift (upcoming work). A separate detector
watches wp-content/uploads for permission drift and unexpected
PHP files. Both are common signs that a site has been written to
by something that should not have write access. Same cadence as
the hardening sweep, same upcoming ship target.
Weekly audit. The drift watcher that runs the equivalent
of the grep "^changed:" analysis above ships today. SGuard reads
the hardening log, surfaces sites that re-drift week after week,
and raises a recurring incident on the noisiest accounts.
The bulk loop itself is not in scope, by design. Editing
wp-config.php across every account on a server in a single
sweep is exactly the kind of multi-tenant change that should
require a human approval at minimum and a human runner ideally.
SGuard will detect drift, surface the runbook, link this post,
and stop short of running the loop on your behalf. The
WordPress hardening cheat sheet
is the per-directive reference for everything the loop applies.
مقالات ذات صلة
- قراءة 2 دقيقة
Disable WP-Cron across every WordPress site on cPanel
Disable WP-Cron across every WordPress site on a cPanel server When is stacking and PHP-FPM is melting, you do not want to hand-edit thirty files. This script does it fleet-wide, idempotently, with a dry-run flag and a stagger so the replac
- قراءة 14 دقيقة
Patchman activation breaks PHP sites: memory_limit gotcha
Patchman activation breaks PHP sites: memorylimit gotcha The ticket landed mid-morning. Thirteen WordPress sites on were intermittently returning 500s. Not all at once, not on a clean five-minute beat, not correlated with traffic. The sites
- قراءة 8 دقيقة
The corrupted WordPress db.php dropin nobody warned you of
The corrupted WordPress db.php dropin nobody warned you about The ticket reads "Error establishing a database connection". You SSH into the box. MySQL is up. works. The other twelve WordPress sites on the same server are loading fine. Only