قراءة 8 دقيقة

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.sh

The 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  done

That 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>&1

Weekly 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 -rn

A 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.

شارك هذه المقالة

XLinkedInEmail
  • قراءة 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