VAPT Remediation OWASP Top 10 WordPress 6.x Linux · Apache · Nginx · PHP-FPM

WordPress Security
Hardening Guide

A complete, copy-paste-ready security baseline for WordPress on self-hosted Linux servers. Every control maps to VAPT findings and includes audit-proof closure statements, verification steps, and severity tags.

15
Security Controls
50+
Config Snippets
4
Server Targets
100%
Free Tools

🛡️ Security Controls Summary

CRITICAL
01 – wp-login / wp-admin IP Restriction — Server-level IP allowlisting. Blocks unauthenticated access entirely.
CRITICAL
02 – Disable XML-RPC — Blocks multicall brute-force amplification and DDoS vectors.
CRITICAL
03 – Brute-Force Protection — Rate limiting + Fail2Ban + plugin lockout. Three-layer defense.
CRITICAL
08 – Secure wp-config.php — Permissions 600, HTTP 403, fresh salts, debug off in production.
CRITICAL
15 – wp-includes & Uploads Lockdown — PHP execution blocked, cgi.fix_pathinfo=0, open_basedir, webshell audit.
HIGH
04 – Two-Factor Authentication — TOTP/FIDO2 enforced for admin and editor roles.
HIGH
05 – Password Policy — Min 12 chars, complexity rules, bypass disabled.
HIGH
10 – File Upload Hardening — No PHP execution in uploads, MIME whitelist enforced.
HIGH
11 – WAF (ModSecurity + Wordfence) — OWASP CRS v4, SQLi/XSS/scanner blocking.
HIGH
12 – Security Headers — HSTS, CSP, X-Frame-Options, nosniff, Referrer-Policy.
MEDIUM
06 – User Enumeration Prevention — REST API locked, author=N blocked, author archives redirected.
MEDIUM
07 – Version Disclosure Removal — Generator, asset ver strings, readme.html, fingerprint files blocked.
MEDIUM
09 – Directory Listing Disabled — Options -Indexes / autoindex off on all WordPress directories.
MEDIUM
13 – Logging & Monitoring — Nginx enhanced format, auditd on critical files, WP Activity Log, 90-day retention.
MEDIUM
14 – Backup Security — GPG-encrypted, outside web root, 600 permissions, 30-day retention, cron-scheduled.
01
Restrict wp-login.php & wp-admin by IP
⚠️ Issue
WordPress admin login page and dashboard are publicly accessible, exposing them to automated brute-force, credential-stuffing, and targeted attacks.
🔥 Risk
Unauthorized admin access, site takeover, data exfiltration, ransomware deployment.
📁 Files & Paths
/var/www/html/.htaccess /etc/apache2/sites-enabled/your-site.conf /etc/nginx/sites-enabled/your-site.conf
🔧 Fix — Apache (.htaccess or VirtualHost)
/var/www/html/.htaccess
# ─── Restrict wp-login.php ───────────────────────────────────
<Files wp-login.php>
    Order deny,allow
    Deny from all
    # Replace with your office/VPN IP(s)
    Allow from 203.0.113.10
    Allow from 203.0.113.20
</Files>

# ─── Restrict wp-admin directory ─────────────────────────────
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REQUEST_URI} ^/wp-admin
    RewriteCond %{REMOTE_ADDR} !^203\.0\.113\.10$
    RewriteCond %{REMOTE_ADDR} !^203\.0\.113\.20$
    RewriteRule ^ - [F,L]
</IfModule>

# ─── Allow admin-ajax.php (required by plugins) ──────────────
<Files admin-ajax.php>
    Order allow,deny
    Allow from all
</Files>
🔧 Fix — Nginx (server block)
/etc/nginx/sites-enabled/your-site.conf
# ─── Restrict wp-login.php ───────────────────────────────────
location = /wp-login.php {
    allow 203.0.113.10;
    allow 203.0.113.20;
    deny  all;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

# ─── Restrict /wp-admin/ ─────────────────────────────────────
location ~ ^/wp-admin/ {
    allow 203.0.113.10;
    allow 203.0.113.20;
    deny  all;
    try_files $uri $uri/ /index.php?$args;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

# ─── Always allow admin-ajax.php ─────────────────────────────
location = /wp-admin/admin-ajax.php {
    allow all;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
👥 Responsible
Web Server Admin Linux/System Admin
🔄 Service Restart
⟳ Apache: systemctl reload apache2
⟳ Nginx: systemctl reload nginx
Verification
# From an unauthorized IP — expect HTTP 403 $ curl -I https://your-site.com/wp-login.php → HTTP/2 403 # From an authorized IP — expect redirect or 200 $ curl -I --interface 203.0.113.10 https://your-site.com/wp-login.php → HTTP/2 200 or 302 # Verify /wp-admin/ also blocked $ curl -I https://your-site.com/wp-admin/ → HTTP/2 403 # admin-ajax.php must remain accessible (plugins depend on it) $ curl -I https://your-site.com/wp-admin/admin-ajax.php → HTTP/2 200 or 400 (not 403)
📋 VAPT Closure Statement
🔒
Audit Closure
Access to wp-login.php and the /wp-admin/ directory has been restricted at the web server layer (Apache/Nginx) via IP allowlisting, returning HTTP 403 for all unauthenticated source addresses. The control has been verified to block external access while preserving admin-ajax.php availability for plugin functionality.
02
Disable XML-RPC Securely
⚠️ Issue
XML-RPC is enabled by default, allowing remote method invocations. Attackers abuse the system.multicall method to perform thousands of login attempts in a single HTTP request, bypassing traditional brute-force limits.
🔥 Risk
Credential bruteforce amplification (1 request = 500 guesses), DDoS amplification, unauthorized remote content publication.
📁 Files & Paths
/var/www/html/.htaccess/etc/nginx/sites-enabled/your-site.conf/var/www/html/wp-content/themes/your-theme/functions.php
🔧 Fix — Apache: Block at server level
/var/www/html/.htaccess
# Block xmlrpc.php completely
<Files xmlrpc.php>
    Order deny,allow
    Deny from all
</Files>
🔧 Fix — Nginx
/etc/nginx/sites-enabled/your-site.conf
location = /xmlrpc.php {
    deny all;
    access_log off;
    log_not_found off;
    return 444;
}
🔧 Fix — WordPress layer (Defense-in-depth)
/var/www/html/wp-content/themes/your-theme/functions.php
// Disable XML-RPC completely
add_filter( 'xmlrpc_enabled', '__return_false' );

// Remove the X-Pingback header
add_filter( 'wp_headers', function( $headers ) {
    unset( $headers['X-Pingback'] );
    return $headers;
} );

// Disable pingbacks (additional attack vector)
add_filter( 'xmlrpc_methods', function( $methods ) {
    unset( $methods['pingback.ping'] );
    unset( $methods['pingback.extensions.getPingbacks'] );
    return $methods;
} );

⚠️ Add to a child theme's functions.php or a custom must-use plugin — not core files.

👥 Responsible
Web Server Admin WordPress Developer
🔄 Restart
⟳ Apache: systemctl reload apache2
⟳ Nginx: systemctl reload nginx
Verification
# Expect HTTP 403, 444, or connection reset $ curl -I https://your-site.com/xmlrpc.php → HTTP/2 403 (Apache) or connection refused (Nginx 444) # Verify no X-Pingback header in response $ curl -I https://your-site.com/ | grep -i pingback → (empty — no X-Pingback header present) # Send a test XML-RPC call — should fail $ curl -s -d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' https://your-site.com/xmlrpc.php → Access denied or empty response
📋 VAPT Closure Statement
🔒
Audit Closure
XML-RPC endpoint (xmlrpc.php) has been disabled at both the web server level (returning HTTP 403/444) and at the WordPress application layer via the xmlrpc_enabled filter. The X-Pingback response header has been suppressed. Verification confirms the endpoint is inaccessible to external requests.
03
Brute-Force Protection & Login Rate Limiting
⚠️ Issue
WordPress imposes no native limit on authentication attempts, making it susceptible to automated credential-stuffing and password-spray attacks.
🔥 Risk
Account takeover via dictionary/brute-force attacks, server resource exhaustion, DoS conditions.
📁 Files & Paths
/etc/fail2ban/jail.local/etc/fail2ban/filter.d/wordpress.conf/etc/nginx/sites-enabled/your-site.conf
🔧 Fix 1 — Nginx Rate Limiting
/etc/nginx/nginx.conf (http block)
# Define login rate-limit zone (10 req/min per IP)
limit_req_zone $binary_remote_addr zone=wp_login:10m rate=10r/m;
/etc/nginx/sites-enabled/your-site.conf
location = /wp-login.php {
    limit_req zone=wp_login burst=5 nodelay;
    limit_req_status 429;
    allow 203.0.113.10;  # your IP
    deny  all;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
🔧 Fix 2 — Fail2Ban for WordPress Login (Apache & Nginx)
/etc/fail2ban/filter.d/wordpress.conf
[Definition]
failregex = ^<HOST> .* "POST /wp-login.php
            ^<HOST> .* "POST /xmlrpc.php
ignoreregex =
/etc/fail2ban/jail.local
[wordpress-login]
enabled   = true
filter    = wordpress
logpath   = /var/log/nginx/access.log
            # For Apache: /var/log/apache2/access.log
maxretry  = 5
findtime  = 300
bantime   = 3600
action    = iptables-multiport[name=wordpress, port="80,443"]
🔌 Fix 3 — WordPress Plugin Layer (Limit Login Attempts Reloaded)

Install Limit Login Attempts Reloaded (free, 1M+ installs) from WordPress.org. Recommended configuration:

  • Lockout after 3 failed attempts
  • Lockout duration: 20 minutes
  • Increase to 24 hours after 4 lockouts
  • Enable GDPR-compliant IP logging
  • Email notification on lockout
👥 Responsible
Linux/System Admin Web Server Admin WordPress Developer
🔄 Restart
systemctl restart fail2ban
systemctl reload nginx
Verification
# Trigger rate limit — send 15 rapid POST requests $ for i in {1..15}; do curl -s -o /dev/null -w "%{http_code}\n" -X POST https://your-site.com/wp-login.php; done → First 10: 200 or 302, then: 429 # Check fail2ban status $ fail2ban-client status wordpress-login → Shows banned IPs and statistics # Check banned IPs in iptables $ iptables -L f2b-wordpress -n --line-numbers → Lists currently banned IP addresses
📋 VAPT Closure Statement
🔒
Audit Closure
Brute-force protection has been implemented via Nginx rate limiting (10 req/min, burst 5) returning HTTP 429 on threshold breach, backed by Fail2Ban which enforces a 1-hour IP ban after 5 failed login attempts within 5 minutes. Application-level lockout via the Limit Login Attempts plugin provides an additional third layer of defense. All three controls are active and verified.
04
Enable & Enforce Two-Factor Authentication (2FA)
⚠️ Issue
WordPress uses single-factor authentication by default. Compromised credentials alone are sufficient for full admin access.
🔥 Risk
Full admin compromise via stolen, leaked, or phished credentials. Renders password policies insufficient alone.
🔧 Fix — Install & Configure Two Factor Plugin
WP CLI Installation
# Install via WP-CLI
$ wp plugin install two-factor --activate --path=/var/www/html

# Verify activation
$ wp plugin status two-factor --path=/var/www/html
🔧 Fix — Force 2FA for Admin roles (functions.php)
/var/www/html/wp-content/themes/your-theme/functions.php
/**
 * Force 2FA enrollment for Administrators and Editors.
 * Redirect to profile page if 2FA is not configured.
 */
add_action( 'init', function() {
    if ( ! is_user_logged_in() ) return;

    $user = wp_get_current_user();
    $required_roles = [ 'administrator', 'editor' ];

    if ( array_intersect( $required_roles, (array) $user->roles ) ) {
        if ( class_exists( 'Two_Factor_Core' ) ) {
            $providers = Two_Factor_Core::get_enabled_providers_for_user( $user );
            if ( empty( $providers ) && ! doing_action( 'profile_update' ) ) {
                if ( ! is_admin() || ! in_array( $GLOBALS['pagenow'], ['profile.php', 'user-edit.php'] ) ) {
                    wp_redirect( admin_url( 'profile.php#two-factor-options' ) );
                    exit;
                }
            }
        }
    }
} );
👥 Responsible
WordPress Developer Linux/System Admin
🔌 Supported Methods
  • TOTP (Google Authenticator, Authy)
  • FIDO2/WebAuthn (hardware keys)
  • Email-based OTP
  • Backup verification codes
Verification
# Log in as admin — should be prompted for 2FA after password # Navigate to: https://your-site.com/wp-admin/ → should redirect to 2FA prompt # Check via WP-CLI which users have 2FA enabled $ wp user list --role=administrator --fields=ID,user_login --path=/var/www/html $ wp user meta get <user_id> _two_factor_enabled_providers --path=/var/www/html → Should list configured providers (totp, fido-u2f, etc.)
📋 VAPT Closure Statement
🔒
Audit Closure
Multi-factor authentication (TOTP-based) has been enforced for all Administrator and Editor-role accounts using the WordPress Two Factor plugin. Users without 2FA enrollment are programmatically redirected to configure a second factor before accessing any admin functionality. FIDO2 hardware key support is available for privileged users.
05
Strong Password Policy Enforcement
⚠️ Issue
WordPress allows weak passwords — admins can bypass the "Weak Password" warning. No minimum complexity or length enforcement exists by default.
🔥 Risk
Accounts with weak passwords are vulnerable to dictionary and credential-stuffing attacks, especially if credentials are reused across services.
🔧 Fix — Custom Password Validation + Bypass Prevention
/var/www/html/wp-content/themes/your-theme/functions.php
/**
 * Enforce strong password policy for Admin/Editor roles.
 * Min 12 chars, uppercase, lowercase, number, special char.
 */
add_action( 'user_profile_update_errors', 'enforce_strong_password', 10, 3 );
add_action( 'validate_password_reset', 'enforce_strong_password', 10, 2 );

function enforce_strong_password( $errors, $update = null, $user = null ) {
    $pass1 = isset( $_POST['pass1'] ) ? trim( $_POST['pass1'] ) : '';
    if ( empty( $pass1 ) ) return;

    $errors_found = [];
    if ( strlen( $pass1 ) < 12 )
        $errors_found[] = 'At least 12 characters';
    if ( ! preg_match( '/[A-Z]/', $pass1 ) )
        $errors_found[] = 'One uppercase letter';
    if ( ! preg_match( '/[a-z]/', $pass1 ) )
        $errors_found[] = 'One lowercase letter';
    if ( ! preg_match( '/[0-9]/', $pass1 ) )
        $errors_found[] = 'One number';
    if ( ! preg_match( '/[\W_]/', $pass1 ) )
        $errors_found[] = 'One special character (!@#$%^&*)';

    if ( ! empty( $errors_found ) ) {
        $errors->add(
            'weak_password',
            'Password must include: ' . implode( ', ', $errors_found ) . '.'
        );
    }
}

// Prevent admins from bypassing the weak password check
add_filter( 'user_profile_update_errors', function( $errors ) {
    if ( isset( $_POST['pw_weak'] ) ) {
        unset( $_POST['pw_weak'] );
    }
    return $errors;
}, 1 );
👥 Responsible
WordPress Developer
📏 Policy Requirements
  • Minimum 12 characters
  • At least 1 uppercase letter
  • At least 1 lowercase letter
  • At least 1 number
  • At least 1 special character
  • No bypass for weak passwords
Verification
# Log in to WordPress admin → Users → Edit Profile # Set password to "test123" → click Update Profile → Should display error: "Password must include: At least 12 characters, One uppercase letter..." # Set password to "Str0ng!Pass#2026" → should succeed
📋 VAPT Closure Statement
🔒
Audit Closure
A password complexity policy enforcing a minimum of 12 characters including uppercase, lowercase, numeric, and special characters has been implemented at the WordPress application layer. The administrator weak-password bypass mechanism has been disabled. Policy is enforced on both profile updates and password resets.
06
Prevent User Enumeration via REST API & Author URLs
⚠️ Issue
WordPress exposes usernames via: (a) /?author=1 redirect, (b) REST API /wp-json/wp/v2/users, and (c) author archive pages. Attackers use this to enumerate valid usernames for credential attacks.
🔥 Risk
Valid username disclosure reduces brute-force effort by 50%. Combined with leaked password databases, it enables targeted credential attacks.
🔧 Fix 1 — Block author=N enumeration (Apache & Nginx)
/var/www/html/.htaccess (Apache)
# Block author scan enumeration
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{QUERY_STRING} author=\d
    RewriteRule ^ /? [L,R=301]
</IfModule>
/etc/nginx/sites-enabled/your-site.conf (Nginx)
# Block ?author=N enumeration
if ( $query_string ~* "author=\d+" ) {
    return 403;
}
🔧 Fix 2 — Restrict REST API user endpoint + Disable author archives
/var/www/html/wp-content/themes/your-theme/functions.php
/**
 * Restrict /wp-json/wp/v2/users to authenticated requests only.
 * Returns 401 for unauthenticated users.
 */
add_filter( 'rest_endpoints', function( $endpoints ) {
    if ( isset( $endpoints['/wp/v2/users'] ) ) {
        foreach ( $endpoints['/wp/v2/users'] as $key => $endpoint ) {
            $endpoints['/wp/v2/users'][$key]['permission_callback'] = function() {
                return current_user_can( 'list_users' );
            };
        }
    }
    if ( isset( $endpoints['/wp/v2/users/(?P<id>[\d]+)'] ) ) {
        foreach ( $endpoints['/wp/v2/users/(?P<id>[\d]+)'] as $key => $endpoint ) {
            $endpoints['/wp/v2/users/(?P<id>[\d]+)'][$key]['permission_callback'] = function() {
                return current_user_can( 'list_users' );
            };
        }
    }
    return $endpoints;
} );

// Disable author archives to prevent enumeration via URL
add_action( 'template_redirect', function() {
    if ( is_author() ) {
        wp_redirect( home_url( '/' ), 301 );
        exit;
    }
} );
👥 Responsible
WordPress Developer Web Server Admin
🔄 Restart
systemctl reload apache2 / nginx
Verification
# Test author enumeration via query string — expect 403 or redirect $ curl -I "https://your-site.com/?author=1" → HTTP/2 403 or 301 redirect to homepage # Test REST API users endpoint — expect 401 for unauthenticated $ curl -s https://your-site.com/wp-json/wp/v2/users | python3 -m json.tool → {"code":"rest_forbidden","message":"Sorry, you are not allowed..."} # Test author archive URL — expect redirect $ curl -I "https://your-site.com/author/admin/" → HTTP/2 301 → redirects to /
📋 VAPT Closure Statement
🔒
Audit Closure
Username enumeration vectors have been mitigated: the ?author=N query parameter is blocked at the web server layer, the /wp-json/wp/v2/users REST endpoint requires authentication (returns HTTP 401 for unauthenticated requests), and author archive pages redirect to the homepage. No usernames are publicly discoverable through these attack vectors.
07
Remove WordPress Version Disclosure
⚠️ Issue
WordPress version is exposed via: HTML meta generator tag, RSS feed, readme.html, script/style query strings (?ver=6.x), and the /wp-includes/ directory. Attackers use this for targeted exploit selection.
🔥 Risk
Version fingerprinting enables precise CVE targeting. Even a 1-day delay in patching creates a known-exploit window if the version is disclosed.
🔧 Fix — Remove version from all disclosure points (functions.php)
/var/www/html/wp-content/themes/your-theme/functions.php
// 1. Remove version from <meta name="generator">
remove_action( 'wp_head', 'wp_generator' );

// 2. Remove version query strings from scripts and styles
add_filter( 'style_loader_src', 'remove_wp_version_strings', 9999 );
add_filter( 'script_loader_src', 'remove_wp_version_strings', 9999 );

function remove_wp_version_strings( $src ) {
    if ( strpos( $src, 'ver=' ) !== false ) {
        $src = remove_query_arg( 'ver', $src );
    }
    return $src;
}

// 3. Remove version from RSS feeds
add_filter( 'the_generator', '__return_empty_string' );

// 4. Remove WP version from login page
add_filter( 'login_headerurl', function() { return home_url(); } );
🔧 Fix — Block fingerprinting files at server level (Apache)
/var/www/html/.htaccess
# Block WordPress fingerprinting files
<FilesMatch "^(readme|license|licenc[s|e]|changelog)\.(html|txt|md)$">
    Order deny,allow
    Deny from all
</FilesMatch>

# Block backup and config disclosure files
<FilesMatch "\.(bak|config|sql|fla|psd|ini|log|sh|inc|swp|dist)$">
    Order deny,allow
    Deny from all
</FilesMatch>
Shell — delete disclosure files
$ rm /var/www/html/readme.html
$ rm /var/www/html/license.txt
$ rm -f /var/www/html/wp-admin/install.php   # if already installed
👥 Responsible
WordPress Developer Linux/System Admin
🔄 Restart
systemctl reload apache2 / nginx
Verification
# Check HTML source for generator meta tag $ curl -s https://your-site.com/ | grep -i generator → (empty — no generator tag visible) # Check if version appears in script/style URLs $ curl -s https://your-site.com/ | grep -oP 'ver=[0-9.]+' | head → (empty — no version strings in asset URLs) # readme.html should return 403 $ curl -I https://your-site.com/readme.html → HTTP/2 403 # RSS feed should not include WordPress version $ curl -s https://your-site.com/feed/ | grep -i generator → (empty or generic)
📋 VAPT Closure Statement
🔒
Audit Closure
WordPress version disclosure has been eliminated from all known vectors: HTML meta generator tag removed, asset version query strings stripped, RSS generator feed suppressed, readme.html and license.txt files blocked (HTTP 403) or deleted, and fingerprinting file extensions blocked at the web server layer. Verification confirms no version information is publicly accessible.
08
Secure wp-config.php (Permissions, Keys & Secrets)
⚠️ Issue
wp-config.php contains database credentials, secret keys, and salts. Incorrect file permissions, default keys, or web-accessible location can expose all these secrets.
🔥 Risk
Complete database compromise, session hijacking (weak salts), cryptographic downgrade, full site takeover.
🔧 Fix 1 — Set strict file permissions
Shell — File permissions hardening
# Set owner to web server user (www-data on Ubuntu/Debian)
$ chown www-data:www-data /var/www/html/wp-config.php

# Restrict to owner read/write only (no group, no world)
$ chmod 600 /var/www/html/wp-config.php

# Move wp-config.php ONE level above web root (optional but recommended)
$ mv /var/www/html/wp-config.php /var/www/wp-config.php
# WordPress auto-discovers it one level up — no code change needed

# Verify permissions
$ ls -la /var/www/wp-config.php
→ -rw------- 1 www-data www-data
🔧 Fix 2 — Block direct HTTP access (.htaccess)
/var/www/html/.htaccess
# Block direct access to wp-config.php
<Files wp-config.php>
    Order deny,allow
    Deny from all
</Files>
🔧 Fix 3 — Generate fresh cryptographic keys and salts
Shell — Generate new salts from WordPress API
# Generate fresh keys from WordPress API and paste into wp-config.php
$ curl -s https://api.wordpress.org/secret-key/1.1/salt/

# Replace existing define() salt lines in wp-config.php with output above:
define( 'AUTH_KEY',         '... generated value ...' );
define( 'SECURE_AUTH_KEY',  '... generated value ...' );
define( 'LOGGED_IN_KEY',    '... generated value ...' );
define( 'NONCE_KEY',        '... generated value ...' );
define( 'AUTH_SALT',        '... generated value ...' );
define( 'SECURE_AUTH_SALT', '... generated value ...' );
define( 'LOGGED_IN_SALT',   '... generated value ...' );
define( 'NONCE_SALT',       '... generated value ...' );
🔧 Fix 4 — Hardening flags in wp-config.php
wp-config.php — Security flags to add
// Force SSL for admin and logins
define( 'FORCE_SSL_ADMIN', true );

// Disable file editing from WP admin dashboard
define( 'DISALLOW_FILE_EDIT', true );

// Disable plugin/theme installation/updates from dashboard
define( 'DISALLOW_FILE_MODS', true );

// Limit post revisions to reduce DB bloat and attack surface
define( 'WP_POST_REVISIONS', 3 );

// NEVER enable debug in production
define( 'WP_DEBUG',         false );
define( 'WP_DEBUG_LOG',     false );
define( 'WP_DEBUG_DISPLAY', false );
👥 Responsible
Linux/System Admin WordPress Developer
Verification
$ stat -c "%a %n" /var/www/html/wp-config.php → 600 $ curl -I https://your-site.com/wp-config.php → HTTP/2 403 # File editor must be disabled in WP admin # Appearance → should NOT show "Theme Editor"
📋 VAPT Closure Statement
🔒
Audit Closure
wp-config.php has been secured: file permissions set to 600 (owner read/write only), direct HTTP access blocked returning HTTP 403, cryptographic keys and salts regenerated using the official WordPress API, SSL enforced for all admin sessions, and the dashboard file editor disabled. WP_DEBUG is confirmed disabled in production to prevent information disclosure.
09
Disable Directory Listing
⚠️ Issue
When a directory lacks an index file, Apache/Nginx may display its full file listing, exposing plugin names, theme structures, backup files, and configuration details.
🔥 Risk
Information disclosure of file structure, plugin versions (aiding targeted CVE attacks), exposed backup files and configuration artifacts.
🔧 Fix — Apache (global + .htaccess)
/etc/apache2/apache2.conf — global config
# Disable directory listing and CGI execution globally
<Directory /var/www/html>
    Options -Indexes -ExecCGI
    AllowOverride All
    Require all granted
</Directory>
/var/www/html/.htaccess — fallback
Options -Indexes
🔧 Fix — Nginx
/etc/nginx/sites-enabled/your-site.conf
# Disable autoindex (directory listing)
autoindex off;

# Block direct access to wp-includes PHP files
location ~* /wp-includes/.*.php$ { deny all; return 403; }

# Block PHP execution in uploads
location ~* /wp-content/uploads/.*\.php$ { deny all; return 403; }
👥 Responsible
Web Server Admin Linux/System Admin
🔄 Restart
systemctl reload apache2 / nginx
Verification
# Test directory listing on uploads and plugin directories $ curl -I https://your-site.com/wp-content/uploads/ → HTTP/2 403 $ curl -I https://your-site.com/wp-content/plugins/ → HTTP/2 403 $ curl -s https://your-site.com/wp-content/ | grep -i "Index of" → (empty — no directory listing)
📋 VAPT Closure Statement
🔒
Audit Closure
Directory listing (Options Indexes / autoindex) has been disabled at the web server configuration level. Requests to directories without an index file return HTTP 403. Direct PHP execution within wp-includes/ and wp-content/uploads/ directories has been blocked as an additional defense measure.
10
File Upload Hardening
⚠️ Issue
The WordPress upload directory (/wp-content/uploads/) allows file uploads. If PHP execution is not disabled in this directory, attackers can upload webshells disguised as images.
🔥 Risk
Remote Code Execution (RCE) via uploaded PHP webshells, leading to complete server compromise, data exfiltration, and lateral movement.
🔧 Fix 1 — Disable PHP execution in uploads (Apache)
/var/www/html/wp-content/uploads/.htaccess (create this file)
# Disable PHP/script execution in uploads directory
<FilesMatch "\.(php|php3|php4|php5|php7|phtml|pl|py|jsp|asp|sh|cgi)$">
    Order deny,allow
    Deny from all
</FilesMatch>

<IfModule mod_php.c>
    php_flag engine off
</IfModule>
<IfModule mod_php8.c>
    php_flag engine off
</IfModule>
🔧 Fix 2 — Nginx: block PHP in uploads + MIME enforcement
/etc/nginx/sites-enabled/your-site.conf
# Block PHP execution in uploads directory
location ~* /wp-content/uploads/.*\.(php|phtml|php3|php4|php5|pl|py|jsp|asp|sh)$ {
    deny all;
    return 403;
}

# Force nosniff on all served upload content
location ~* ^/wp-content/uploads/ {
    add_header X-Content-Type-Options nosniff always;
    location ~* \.(jpg|jpeg|png|gif|webp|mp4|mp3|pdf|zip|doc|docx)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}
🔧 Fix 3 — Restrict allowed upload MIME types (functions.php)
/var/www/html/wp-content/themes/your-theme/functions.php
/**
 * Restrict uploaded file MIME types to safe list.
 * Blocks SVG by default (XSS risk) — add only if needed with sanitization.
 */
add_filter( 'upload_mimes', function( $mimes ) {
    $allowed = [
        'jpg|jpeg|jpe' => 'image/jpeg',
        'gif'          => 'image/gif',
        'png'          => 'image/png',
        'webp'         => 'image/webp',
        'pdf'          => 'application/pdf',
        'doc|docx'     => 'application/msword',
        'mp4|m4v'      => 'video/mp4',
        'mp3|m4a'      => 'audio/mpeg',
    ];
    return $allowed;
}, 1 );
👥 Responsible
Web Server Admin WordPress Developer Linux/System Admin
Verification
# Upload a test PHP file via WordPress media library # → Should be rejected: "Sorry, this file type is not permitted" # Even if somehow uploaded, execution must be blocked $ curl -I https://your-site.com/wp-content/uploads/test.php → HTTP/2 403 # Confirm legitimate image upload works # → Upload a .jpg via WordPress Media → should succeed
📋 VAPT Closure Statement
🔒
Audit Closure
PHP and script execution has been disabled in the WordPress uploads directory via server-level configuration (returning HTTP 403 for PHP requests). Allowed MIME types have been restricted at the application layer to a defined whitelist. Both server-level blocking and application-level MIME filtering are active, mitigating webshell upload and Remote Code Execution risks.
11
Web Application Firewall (WAF) Configuration
⚠️ Issue
Without a WAF, the application is exposed to OWASP Top 10 attacks: SQL injection, XSS, LFI, path traversal, CSRF, and automated exploit scanners.
🔥 Risk
Exploitation of application-layer vulnerabilities in WordPress core, themes, or plugins resulting in data breach or RCE.
🔧 Fix 1 — Install ModSecurity + OWASP CRS (Apache)
Shell — Install ModSecurity + OWASP Core Rule Set v4
# Install ModSecurity for Apache
$ apt-get install libapache2-mod-security2 -y
$ a2enmod security2

# Download OWASP Core Rule Set (CRS)
$ cd /etc/modsecurity/
$ wget https://github.com/coreruleset/coreruleset/archive/v4.0.0.tar.gz
$ tar -xzf v4.0.0.tar.gz
$ cp coreruleset-4.0.0/crs-setup.conf.example crs-setup.conf

# Enable modsecurity.conf and switch to enforcement mode
$ cp /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf
$ sed -i 's/SecRuleEngine DetectionOnly/SecRuleEngine On/' /etc/modsecurity/modsecurity.conf

$ systemctl restart apache2
🔧 Fix 2 — Wordfence Security Plugin (WP Application Layer)
WP-CLI Installation
# Install Wordfence (free tier has excellent WAF)
$ wp plugin install wordfence --activate --path=/var/www/html

Wordfence free tier provides:

  • WordPress-specific WAF rules
  • Malware scanner for files, themes, and plugins
  • Live traffic monitoring + IP reputation blocking
  • Login security (rate limiting, CAPTCHA)
🔧 Fix 3 — Nginx: Manual WAF rules for common attacks
/etc/nginx/sites-enabled/your-site.conf
# Block SQL injection patterns
set $block_sql_injections 0;
if ( $query_string ~* "union.*select.*\(" ) { set $block_sql_injections 1; }
if ( $query_string ~* "concat.*\(.*\)" )    { set $block_sql_injections 1; }
if ( $block_sql_injections = 1 ) { return 403; }

# Block XSS attempts
set $block_xss 0;
if ( $query_string ~* "<script>" )          { set $block_xss 1; }
if ( $query_string ~* "javascript:" )       { set $block_xss 1; }
if ( $block_xss = 1 )                       { return 403; }

# Block common vulnerability scanners
if ( $http_user_agent ~* (nikto|sqlmap|nmap|masscan|zgrab) ) { return 403; }

# Block requests with no User-Agent
if ( $http_user_agent = "" ) { return 403; }
👥 Responsible
Linux/System Admin Web Server Admin WordPress Developer
Verification
# Test SQLi blocking $ curl -I "https://your-site.com/?id=1+UNION+SELECT+1,2,3" → HTTP/2 403 # Test XSS blocking $ curl -I "https://your-site.com/?q=<script>alert(1)</script>" → HTTP/2 403 # Verify ModSecurity is active $ apachectl -M | grep security → security2_module (shared) # Check Wordfence WAF status # WP Admin → Wordfence → Firewall
📋 VAPT Closure Statement
🔒
Audit Closure
A Web Application Firewall has been deployed at both the server layer (ModSecurity with OWASP CRS v4 in enforcement mode) and the WordPress application layer (Wordfence Security plugin). SQL injection, XSS, and path traversal patterns are actively blocked. Known vulnerability scanner User-Agents are rejected. WAF efficacy verified via controlled injection test payloads returning HTTP 403.
12
Security Headers (HTTP Response Headers)
⚠️ Issue
Missing HTTP security headers leave the site vulnerable to clickjacking, MIME-sniffing attacks, cross-site scripting (via content injection), and SSL stripping.
🔥 Risk
XSS via content injection, clickjacking, session hijacking via HTTP downgrade, MIME confusion attacks, information leakage via Referer headers.
🔧 Fix — Apache (VirtualHost or .htaccess)
/etc/apache2/sites-enabled/your-site.conf
<IfModule mod_headers.c>
    # Prevent clickjacking
    Header always set X-Frame-Options "SAMEORIGIN"
    # Prevent MIME type sniffing
    Header always set X-Content-Type-Options "nosniff"
    # XSS filter (legacy browsers)
    Header always set X-XSS-Protection "1; mode=block"
    # HSTS — 1 year, include subdomains, submit to preload list
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    # Referrer Policy
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    # Permissions Policy — disable unused browser features
    Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()"
    # Content Security Policy (adjust domains as needed)
    Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';"
    # Remove server version disclosure
    Header always unset X-Powered-By
    Header always unset Server
</IfModule>

# Disable server signature in apache2.conf
ServerTokens Prod
ServerSignature Off
🔧 Fix — Nginx
/etc/nginx/sites-enabled/your-site.conf (server block)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
server_tokens off;
fastcgi_hide_header X-Powered-By;
/etc/php/8.2/fpm/php.ini — also hide PHP version
expose_php = Off
👥 Responsible
Web Server Admin Linux/System Admin
🔄 Restart
systemctl reload apache2
systemctl reload nginx php8.2-fpm
Verification
$ curl -I https://your-site.com/ 2>&1 | grep -E '(Strict|X-Frame|X-Content|Content-Security|Referrer|Permissions|X-XSS)' → strict-transport-security: max-age=31536000; includeSubDomains; preload → x-frame-options: SAMEORIGIN → x-content-type-options: nosniff → content-security-policy: default-src 'self'; ... → referrer-policy: strict-origin-when-cross-origin # Online verification # https://securityheaders.com → Grade: A
📋 VAPT Closure Statement
🔒
Audit Closure
All recommended HTTP security response headers have been implemented: HSTS (max-age 1 year with includeSubDomains), X-Frame-Options (SAMEORIGIN), X-Content-Type-Options (nosniff), Content-Security-Policy, Referrer-Policy, and Permissions-Policy. Server version tokens have been suppressed. Configuration verified via curl and securityheaders.com achieving a Grade A rating.
13
Logging & Monitoring
⚠️ Issue
Without proper logging, attacks go undetected and post-incident forensics are impossible. Default WordPress logging is minimal. Server access logs may not capture security-relevant events.
🔥 Risk
Inability to detect ongoing attacks, delayed incident response, failure to meet audit and compliance requirements (ISO 27001, CERT-In guidelines).
🔧 Fix 1 — Nginx enhanced log format
/etc/nginx/nginx.conf (http block)
# Enhanced log format with User-Agent and request time
log_format security_combined
    '$remote_addr - $remote_user [$time_local] '
    '"$request" $status $body_bytes_sent '
    '"$http_referer" "$http_user_agent" '
    '$request_time $upstream_response_time';

access_log /var/log/nginx/access.log security_combined;
error_log  /var/log/nginx/error.log warn;
🔧 Fix 2 — Auditd: track wp-config.php and critical files
Shell — Install auditd and add persistent rules
# Install auditd
$ apt-get install auditd -y

# Add immediate rules
$ auditctl -w /var/www/html/wp-config.php -p rwa -k wp-config-access
$ auditctl -w /var/www/html/wp-content/uploads -p w -k uploads-write
$ auditctl -w /var/www/html/.htaccess -p rwa -k htaccess-access

# Make rules persistent across reboots
$ echo '-w /var/www/html/wp-config.php -p rwa -k wp-config-access' >> /etc/audit/rules.d/wordpress.rules
$ echo '-w /var/www/html/wp-content/uploads -p w -k uploads-write' >> /etc/audit/rules.d/wordpress.rules
$ echo '-w /var/www/html/.htaccess -p rwa -k htaccess-access' >> /etc/audit/rules.d/wordpress.rules

$ systemctl restart auditd
🔧 Fix 3 — WP Activity Log Plugin (free, #1 WP audit log)
WP-CLI Installation
# Install WP Activity Log (formerly WP Security Audit Log)
$ wp plugin install wp-security-audit-log --activate --path=/var/www/html

Tracks: logins, failed logins, user changes, plugin installs, file edits, settings changes, content modifications.

🔧 Fix 4 — Log rotation (90-day retention)
/etc/logrotate.d/wordpress-nginx
/var/log/nginx/access.log /var/log/nginx/error.log {
    daily
    rotate 90         # Keep 90 days
    compress
    delaycompress
    missingok
    notifempty
    sharedscripts
    postrotate
        nginx -s reopen
    endscript
}
👥 Responsible
Linux/System Admin WordPress Developer
Verification
# Verify access logging is active $ tail -f /var/log/nginx/access.log → Should show live requests with full User-Agent and timing # Test auditd is capturing wp-config.php access $ cat /var/www/html/wp-config.php > /dev/null $ ausearch -k wp-config-access | tail -5 → Should show audit entry for the read # Verify log rotation config $ logrotate -d /etc/logrotate.d/wordpress-nginx → Should show rotation plan without errors
📋 VAPT Closure Statement
🔒
Audit Closure
Comprehensive logging is enabled at the web server layer (Nginx enhanced log format), OS kernel layer (auditd watching critical WordPress files with persistent rules), and WordPress application layer (WP Activity Log plugin). Log retention is configured for 90 days with compressed rotation. Audit trail covers authentication events, file modifications, and plugin/settings changes, satisfying forensic evidence requirements.
14
Backup Security Essentials
⚠️ Issue
Backups stored in the web root are publicly downloadable. Unencrypted backups containing database credentials and user data violate data protection principles. Missing backups prevent recovery from ransomware or corruption.
🔥 Risk
Backup files exposed via directory browsing leak full database dumps. No backup = no recovery from attack. Unencrypted backups violate DPDP Act / IT Act compliance.
🔧 Fix 1 — Automated GPG-encrypted backup script
/usr/local/bin/wp-backup.sh
#!/bin/bash
# WordPress Secure Backup Script
# Run via cron: 0 2 * * * /usr/local/bin/wp-backup.sh

SITE_DIR="/var/www/html"
BACKUP_DIR="/var/backups/wordpress"  # OUTSIDE web root
DATE=$(date +%Y%m%d_%H%M%S)
DB_NAME="wordpress_db"
DB_USER="wp_user"
DB_PASS="your_db_password"
GPG_RECIPIENT="backup@yourdomain.com"
RETENTION_DAYS=30

mkdir -p $BACKUP_DIR

# 1. Database backup (encrypted)
mysqldump -u $DB_USER -p$DB_PASS $DB_NAME | \
    gzip | \
    gpg --recipient $GPG_RECIPIENT --encrypt \
    > $BACKUP_DIR/db_${DATE}.sql.gz.gpg

# 2. Files backup (encrypted, exclude cache)
tar -czf - \
    --exclude="$SITE_DIR/wp-content/cache" \
    --exclude="$SITE_DIR/wp-content/uploads/cache" \
    $SITE_DIR | \
    gpg --recipient $GPG_RECIPIENT --encrypt \
    > $BACKUP_DIR/files_${DATE}.tar.gz.gpg

# 3. Set strict permissions on backups
chmod 600 $BACKUP_DIR/*.gpg
chown root:root $BACKUP_DIR/*.gpg

# 4. Remove old backups beyond retention period
find $BACKUP_DIR -name "*.gpg" -mtime +$RETENTION_DAYS -delete

echo "Backup completed: $DATE" >> /var/log/wp-backup.log
Shell — Make executable and schedule via cron
$ chmod +x /usr/local/bin/wp-backup.sh
$ (crontab -l 2>/dev/null; echo "0 2 * * * /usr/local/bin/wp-backup.sh") | crontab -
$ echo "Deny from all" > /var/backups/wordpress/.htaccess
🔧 Fix 2 — UpdraftPlus Plugin for managed backups
WP-CLI Installation
$ wp plugin install updraftplus --activate --path=/var/www/html

Configure: Daily database, Weekly files. Remote storage: Google Drive, S3, Dropbox, SFTP. Retain 7 daily + 4 weekly + 3 monthly copies.

👥 Responsible
Linux/System Admin WordPress Developer
📋 Backup Checklist
  • Backups stored outside web root
  • Backups encrypted with GPG
  • Remote/offsite copy (3-2-1 rule)
  • File permissions 600 (root only)
  • 30+ day retention policy
  • Restore tested quarterly
  • Backup logs monitored
Verification
$ /usr/local/bin/wp-backup.sh $ ls -la /var/backups/wordpress/ → -rw------- 1 root root ... db_20250421_020001.sql.gz.gpg # Confirm backups NOT accessible via web $ curl -I https://your-site.com/../backups/wordpress/ → HTTP/2 403 or 404 # Test decryption (restore test) $ gpg --decrypt /var/backups/wordpress/db_20250421_020001.sql.gz.gpg | gunzip | head -5 → Should show SQL dump headers # Check cron is scheduled $ crontab -l | grep wp-backup → 0 2 * * * /usr/local/bin/wp-backup.sh
📋 VAPT Closure Statement
🔒
Audit Closure
Automated daily encrypted backups have been implemented via a GPG-encrypted cron script storing files outside the web root with 600 permissions. Backups are replicated to a remote storage destination (3-2-1 rule). Backup files are confirmed inaccessible via HTTP. A 30-day retention policy is enforced with automated cleanup. Restore procedure has been tested and documented.
15
Lock Down /wp-includes/ & /wp-content/uploads/ Public Access
⚠️ Issue
Both /wp-includes/ and /wp-content/uploads/ are publicly accessible with no PHP execution restrictions. wp-includes/ houses WordPress core internals that should never be directly reachable by the public. uploads/ is the most common webshell drop zone — one malicious upload + PHP execution = full RCE. Additionally, cgi.fix_pathinfo=1 (PHP default) lets attackers append /evil.php to any uploaded image URL to trigger PHP execution.
🔥 Risk
Remote Code Execution (RCE) via uploaded webshells in uploads/. Direct exploitation of WordPress core via wp-includes/. cgi.fix_pathinfo path-traversal RCE. Full server takeover, data exfiltration, ransomware, lateral movement.
📁 Affected Paths
/var/www/html/wp-includes/ /var/www/html/wp-content/uploads/ /var/www/html/wp-content/uploads/.htaccess /etc/nginx/sites-enabled/your-site.conf /etc/php/8.2/fpm/php.ini /etc/php/8.2/fpm/pool.d/www.conf
🔧 Fix A1 — Apache: Lock down /wp-includes/
/var/www/html/.htaccess — add inside the WordPress block
# ─── Block PHP execution in wp-includes/ ──────────────────────
<IfModule mod_rewrite.c>
    RewriteEngine On
    # Block all PHP files in wp-includes (except ms-files.php for Multisite)
    RewriteRule ^wp-includes/[^/]+\.php$ - [F,L]
    RewriteRule ^wp-includes/js/tinymce/langs/.+\.php - [F,L]
    RewriteRule ^wp-includes/theme-compat/ - [F,L]
</IfModule>

# Block direct access to sensitive core files
<FilesMatch "^(install|load|ms-files|wp-db|atomlib|pluggable)\.php$">
    Order deny,allow
    Deny from all
</FilesMatch>

Options -Indexes
🔧 Fix A2 — Apache: Lock down /wp-content/uploads/ (dedicated .htaccess)
/var/www/html/wp-content/uploads/.htaccess — create/overwrite this file
# ─── uploads/ hardening ─────────────────────────────────────────

# 1. Block ALL script execution — every variant
<FilesMatch "\.(php|php3|php4|php5|php6|php7|php8|phtml|phar|pl|py|cgi|asp|aspx|sh|bash|rb|lua)$">
    Order deny,allow
    Deny from all
</FilesMatch>

# 2. Kill PHP engine entirely in this directory
<IfModule mod_php.c>
    php_flag engine off
</IfModule>
<IfModule mod_php8.c>
    php_flag engine off
</IfModule>

# 3. Disable directory listing
Options -Indexes

# 4. Block PHP at any path depth inside uploads (catches subdirs)
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteRule \.php$ - [F,L]
    RewriteRule \.phar$ - [F,L]
</IfModule>

# 5. Force nosniff + restrict CSP — prevents SVG/HTML XSS
<IfModule mod_headers.c>
    Header set X-Content-Type-Options "nosniff"
    Header set Content-Security-Policy "default-src 'none'"
</IfModule>

# 6. Whitelist ONLY safe static extensions
<IfModule mod_rewrite.c>
    RewriteCond %{REQUEST_FILENAME} -f
    RewriteRule !\.(jpg|jpeg|png|gif|webp|ico|pdf|mp4|mp3|mov|doc|docx|xls|xlsx|ppt|pptx|zip|txt|woff|woff2|ttf|eot)$ - [F,L]
</IfModule>
🔧 Fix B1 — Nginx: Lock down /wp-includes/
/etc/nginx/sites-enabled/your-site.conf — add inside server{} block
# ─── wp-includes/ lockdown ────────────────────────────────────
location /wp-includes/ {
    autoindex off;

    # Allow ONLY ms-files.php for WordPress Multisite
    location ~ ^/wp-includes/ms-files\.php$ {
        allow all;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    # Block ALL other PHP files in wp-includes
    location ~* /wp-includes/.*\.php$ {
        deny all;
        return 403;
    }

    # Block archive/backup files inside wp-includes
    location ~* /wp-includes/.*\.(log|bak|sql|gz|tar|zip)$ {
        deny all;
        return 403;
    }
}

# Block theme-compat directory
location ~* /wp-includes/theme-compat/ {
    deny all;
    return 403;
}
🔧 Fix B2 — Nginx: Lock down /wp-content/uploads/
/etc/nginx/sites-enabled/your-site.conf — add inside server{} block
# ─── wp-content/uploads/ lockdown ────────────────────────────
location /wp-content/uploads/ {
    autoindex off;

    # HIGH PRIORITY — deny all PHP/script files. Catches double-extension tricks.
    location ~* /wp-content/uploads/.*\.(php|php3|php4|php5|php7|php8|phtml|phar|pl|py|cgi|asp|sh|bash|rb|lua)$ {
        deny all;
        return 403;
        access_log /var/log/nginx/uploads_blocked.log;   # alert in SIEM
    }

    # Prevent SVG and HTML from executing JS (XSS vector)
    location ~* /wp-content/uploads/.*\.(svg|svgz|html|htm|xml)$ {
        add_header Content-Type text/plain;
        add_header Content-Disposition attachment;
        add_header X-Content-Type-Options "nosniff" always;
    }

    # Block hidden/dotfiles (.htaccess, .env, etc.)
    location ~* /wp-content/uploads/\. {
        deny all;
        return 403;
    }

    # Force nosniff + restrictive CSP on all uploads responses
    add_header X-Content-Type-Options "nosniff" always;
    add_header Content-Security-Policy "default-src 'none'" always;

    # Serve only safe static file types
    location ~* \.(jpg|jpeg|png|gif|webp|ico|pdf|mp4|mp3|mov|doc|docx|xls|xlsx|ppt|pptx|zip|txt|woff|woff2|ttf|eot)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
        add_header X-Content-Type-Options "nosniff" always;
    }
}

# Monitor this log for SIEM alerts: tail -f /var/log/nginx/uploads_blocked.log
🔧 Fix C — PHP-FPM: cgi.fix_pathinfo + open_basedir + dangerous functions
/etc/php/8.2/fpm/php.ini — critical runtime hardening
; ─── CRITICAL: Prevent path-info RCE ────────────────────────────
; Without this: attacker uploads image.jpg → accesses image.jpg/evil.php → RCE
cgi.fix_pathinfo = 0

; ─── Restrict PHP file access to web root only ───────────────────
open_basedir = /var/www/html:/tmp:/usr/share/php

; ─── Disable dangerous PHP functions ─────────────────────────────
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source

; ─── Hide PHP version (no X-Powered-By: PHP/8.x header) ──────────
expose_php = Off

; ─── Block remote file inclusion ──────────────────────────────────
allow_url_fopen = Off
allow_url_include = Off

; ─── Limit upload size to what WP actually needs ─────────────────
upload_max_filesize = 10M
post_max_size = 12M
/etc/php/8.2/fpm/pool.d/www.conf — per-pool enforcement
; Restrict this PHP-FPM pool to web root only (overrides php.ini per pool)
php_admin_value[open_basedir] = /var/www/html:/tmp
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
php_admin_flag[allow_url_fopen] = off
php_admin_flag[allow_url_include] = off
🔧 Fix D — Shell: Audit uploads for webshells + fix permissions
Shell — run immediately after deploying the above configs
# ── Scan for existing PHP webshells in uploads ────────────────
$ find /var/www/html/wp-content/uploads/ \
    -name "*.php" -o -name "*.phar" -o -name "*.phtml" -o -name "*.php*"

# If ANY files found — quarantine immediately:
$ mkdir -p /tmp/quarantine
$ find /var/www/html/wp-content/uploads/ -name "*.php*" \
    -exec mv {} /tmp/quarantine/ \;

# Scan for executable files that shouldn't exist
$ find /var/www/html/wp-content/uploads/ -perm /111 -type f

# ── Fix permissions on uploads/ ──────────────────────────────
$ chown -R www-data:www-data /var/www/html/wp-content/uploads/
$ find /var/www/html/wp-content/uploads/ -type d -exec chmod 755 {} \;
$ find /var/www/html/wp-content/uploads/ -type f -exec chmod 644 {} \;

# ── Set wp-includes/ to read-only for web server ─────────────
$ chown -R www-data:www-data /var/www/html/wp-includes/
$ find /var/www/html/wp-includes/ -type f -exec chmod 444 {} \;
$ find /var/www/html/wp-includes/ -type d -exec chmod 555 {} \;
👥 Responsible
Web Server Admin Linux/System Admin WordPress Developer
🔄 Service Restart
⟳ Apache: systemctl reload apache2
⟳ Nginx: systemctl reload nginx
⟳ PHP-FPM: systemctl restart php8.2-fpm
Verification
# ── wp-includes/ tests ──────────────────────────────────────────────── # PHP files in wp-includes must return 403 $ curl -I https://your-site.com/wp-includes/load.php → HTTP/2 403 $ curl -I https://your-site.com/wp-includes/pluggable.php → HTTP/2 403 # WordPress itself must still load $ curl -I https://your-site.com/ → HTTP/2 200 # No directory listing $ curl -s https://your-site.com/wp-includes/ | grep -i "Index of" → (empty) # ── wp-content/uploads/ tests ───────────────────────────────────────── # Direct PHP access must return 403 $ curl -I https://your-site.com/wp-content/uploads/evil.php → HTTP/2 403 # Double-extension trick must be blocked $ curl -I "https://your-site.com/wp-content/uploads/image.jpg.php" → HTTP/2 403 # Legitimate image must still be served $ curl -I https://your-site.com/wp-content/uploads/2025/04/logo.png → HTTP/2 200 # Directory listing blocked $ curl -s https://your-site.com/wp-content/uploads/ | grep -i "Index of" → (empty) # ── PHP-FPM config tests ────────────────────────────────────────────── # cgi.fix_pathinfo must be 0 $ php -r "echo ini_get('cgi.fix_pathinfo');" → 0 # allow_url_include must be Off $ php -r "echo ini_get('allow_url_include');" → (empty = Off) # open_basedir must be set $ php -r "echo ini_get('open_basedir');" → /var/www/html:/tmp
📋 VAPT Closure Statement
🔒
Audit Closure
Public access to /wp-includes/ and /wp-content/uploads/ has been hardened at the web server and PHP runtime layers. All PHP/script execution within both directories is blocked at the server level (HTTP 403). Directory listing is disabled. PHP-FPM is configured with cgi.fix_pathinfo=0 (prevents path-traversal RCE), open_basedir restriction to web root, allow_url_include=Off, and dangerous PHP functions disabled. SVG/HTML uploads are forced to serve as plain text with restrictive CSP to prevent XSS. A post-remediation filesystem audit confirmed no existing PHP webshells in the uploads directory. File permissions: wp-includes/ set to 444/555 (read-only), uploads/ set to 644/755. All controls verified via live curl testing.

📊 VAPT Remediation Tracking Table

Master checklist — update Status column during audit closure review.

# Control Severity Layer Responsible OWASP Ref Status
01Restrict wp-login / wp-admin by IPCRITICALServerWeb AdminA07:2021☐ Open
02Disable XML-RPCCRITICALServer + WPWeb Admin WP DevA05:2021☐ Open
03Brute-Force Protection (Fail2Ban + Rate Limit)CRITICALServer + PluginSys AdminA07:2021☐ Open
04Two-Factor Authentication (2FA)HIGHWordPressWP DevA07:2021☐ Open
05Strong Password PolicyHIGHWordPressWP DevA07:2021☐ Open
06Prevent User Enumeration (REST API)MEDIUMWordPress + ServerWP DevA01:2021☐ Open
07Remove Version DisclosureMEDIUMWordPress + ServerWP DevA05:2021☐ Open
08Secure wp-config.phpCRITICALServer + WordPressSys AdminA02:2021☐ Open
09Disable Directory ListingMEDIUMServerWeb AdminA05:2021☐ Open
10File Upload HardeningHIGHServer + WordPressWeb AdminA03:2021☐ Open
11WAF (ModSecurity + Wordfence)HIGHServer + PluginSys AdminA03:2021☐ Open
12Security Headers (HSTS, CSP, etc.)HIGHServerWeb AdminA05:2021☐ Open
13Logging & Monitoring (auditd + WP Activity Log)MEDIUMServer + PluginSys AdminA09:2021☐ Open
14Backup Security (Encrypted + Offsite)MEDIUMServer + PluginSys AdminA05:2021☐ Open
15wp-includes & Uploads PHP Lockdown + PHP-FPM Hardening ★CRITICALServer + PHPSys Admin Web AdminA03:2021☐ Open
Critical: 5 controls
High: 5 controls
Medium: 5 controls
15 Controls Total · v2025.3 · May 2026 · OWASP WordPress Security Guide