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

WordPress Security
Hardening Guide

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

14
Security Controls
40+
Config Snippets
3
Server Types
100%
Free Tools
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 $ 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' );

// Also 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 = ^ .* "POST /wp-login.php
            ^ .* "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 — Free Plugin (WP Limit Login Attempts)

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

  • 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 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 admin access.
🔥 Risk
Full admin compromise via stolen, leaked, or phished credentials. Renders password policies insufficient alone.
🔧 Fix — Install & Configure Two Factor Plugin

Install the official Two Factor plugin by WordPress.org (free, maintained by WordPress contributors):

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 ) ) {
        // Check if Two Factor plugin is active and 2FA is configured
        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 in functions.php
/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#2025" → 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 (.htaccess / 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 (functions.php)
/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 readme.html and sensitive files at server level
/var/www/html/.htaccess (Apache)
# Block WordPress fingerprinting files
<FilesMatch "^(readme|license|licenc[s|e]|changelog)\.(html|txt|md)$">
    Order deny,allow
    Deny from all
</FilesMatch>

# Block wp-config.php.bak and similar
<FilesMatch "\.(bak|config|sql|fla|psd|ini|log|sh|inc|swp|dist)$">
    Order deny,allow
    Deny from all
</FilesMatch>
Shell command — delete readme.html
# Delete or rename 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
wp-config.php — Replace existing keys/salts
# Generate fresh keys from WordPress API
$ curl -s https://api.wordpress.org/secret-key/1.1/salt/

# Paste the output into wp-config.php replacing lines like:
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 );

// Set ABSPATH to prevent direct file access
define( 'ABSPATH', dirname( __FILE__ ) . '/' );

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

// Disable debug in production (NEVER true in prod)
define( 'WP_DEBUG', false );
define( 'WP_DEBUG_LOG', false );
define( 'WP_DEBUG_DISPLAY', false );
👥 Responsible
Linux/System Admin WordPress Developer
Verification
# Check file permissions (must be 600) $ stat -c "%a %n" /var/www/html/wp-config.php → 600 /var/www/html/wp-config.php # Direct HTTP access must return 403 $ curl -I https://your-site.com/wp-config.php → HTTP/2 403 # File editor must be disabled in WP admin # WP Admin → Appearance → should NOT show "Theme Editor" menu item
📋 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
/etc/apache2/apache2.conf or /var/www/html/.htaccess
# Disable directory listing globally
<Directory /var/www/html>
    Options -Indexes -ExecCGI
    AllowOverride All
    Require all granted
</Directory>

# Alternatively via .htaccess
Options -Indexes
🔧 Fix — Nginx
/etc/nginx/sites-enabled/your-site.conf
server {
    # Disable autoindex (directory listing)
    autoindex off;

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

    # Block direct access to wp-content/uploads php files
    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 (not a directory listing page) $ curl -I https://your-site.com/wp-content/plugins/ → HTTP/2 403 # Ensure no "Index of" text in response body $ 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 dir (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>
🔧 Fix 2 — Nginx: block PHP in uploads
/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;
}

# Restrict file types served from uploads (MIME enforcement)
location ~* ^/wp-content/uploads/ {
    location ~* \.(jpg|jpeg|png|gif|webp|svg|mp4|mp3|pdf|zip|doc|docx)$ {
        add_header X-Content-Type-Options nosniff;
    }
}
🔧 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.
 */
add_filter( 'upload_mimes', function( $mimes ) {
    // Define allowed MIME types
    $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;  // Replace entire MIME list
}, 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 (Apache — Free, open-source WAF)
Shell — Install ModSecurity + OWASP Core Rule Set
# 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
$ cp /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf

# Switch from DetectionOnly to enforcement mode
$ sed -i 's/SecRuleEngine DetectionOnly/SecRuleEngine On/' /etc/modsecurity/modsecurity.conf

$ systemctl restart apache2
🔧 Fix 2 — Wordfence Security Plugin (Free, Recommended for WP)
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 (updated 30-day delay on free)
  • 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 common attack patterns in query strings
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 in 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"

    # HTTP Strict Transport Security (HSTS) — 1 year
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"

    # Referrer Policy — don't leak URL to third-parties
    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>

# Also disable server signature in apache2.conf
ServerTokens Prod
ServerSignature Off
🔧 Fix — Nginx
/etc/nginx/sites-enabled/your-site.conf (server block)
# Security headers
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;

# Hide Nginx version and server tokens
server_tokens off;

# Remove X-Powered-By (set in php.ini or here)
fastcgi_hide_header X-Powered-By;
/etc/php/8.2/fpm/php.ini — also hide PHP
expose_php = Off
👥 Responsible
Web Server Admin Linux/System Admin
🔄 Restart
systemctl reload apache2
systemctl reload nginx php8.2-fpm
Verification
# Check all security headers in response $ 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 — use securityheaders.com $ curl -s "https://securityheaders.com/?q=https://your-site.com" | grep -i grade → Grade: A # Verify server header removed $ curl -I https://your-site.com/ | grep -iE '(server|x-powered-by)' → (empty or "server: nginx" without version)
📋 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 access log with custom format (captures attack patterns)
/etc/nginx/nginx.conf
# 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 access
Shell — Install auditd and add rules
# Install auditd
$ apt-get install auditd -y

# Watch critical WordPress files for read/write
$ 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
$ 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 and 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), 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 encrypted backup script
/usr/local/bin/wp-backup.sh (create this script)
#!/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

# Create backup directory (not web-accessible)
mkdir -p $BACKUP_DIR

# 1. Database backup
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 (exclude cache, tmp files)
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 add to cron
# Make script executable
$ chmod +x /usr/local/bin/wp-backup.sh

# Add to crontab — run at 2 AM daily
$ (crontab -l 2>/dev/null; echo "0 2 * * * /usr/local/bin/wp-backup.sh") | crontab -

# Block web access to backup directory (precaution)
$ echo "Deny from all" > /var/backups/wordpress/.htaccess
🔧 Fix 2 — UpdraftPlus (Free Plugin) for managed backups
WP-CLI Installation
# Install UpdraftPlus (free tier supports remote storage)
$ wp plugin install updraftplus --activate --path=/var/www/html

Configure in WP Admin → Settings → UpdraftPlus Backups:

  • Schedule: Daily database, Weekly files
  • Remote storage: Google Drive, S3, Dropbox, SFTP (free)
  • Retain: 7 daily + 4 weekly + 3 monthly copies
  • Include database, plugins, themes, uploads
  • Email report to admin on completion
👥 Responsible
Linux/System Admin WordPress Developer
📋 Backup Security Checklist
  • Backups stored outside web root
  • Backups encrypted with GPG
  • Remote/offsite copy (3-2-1 rule)
  • File permissions 600 (root only)
  • Retention policy (30+ days)
  • Restore tested quarterly
  • Backup logs monitored
Verification
# Verify backup runs and produces encrypted files $ /usr/local/bin/wp-backup.sh $ ls -la /var/backups/wordpress/ → -rw------- 1 root root ... db_20250421_020001.sql.gz.gpg # Confirm backups are NOT accessible via web $ curl -I https://your-site.com/../backups/wordpress/ → HTTP/2 403 or 404 # Test decryption of backup (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.

📊 Security Controls Summary

Master checklist for VAPT remediation tracking

# Control Severity Layer Responsible Status
01Restrict wp-login / wp-admin by IPCRITICALServerWeb Admin☐ Open
02Disable XML-RPCCRITICALServer + WPWeb Admin WP Dev☐ Open
03Brute-Force Protection (Fail2Ban + Rate Limit)CRITICALServer + PluginSys Admin☐ Open
04Two-Factor Authentication (2FA)HIGHWordPressWP Dev☐ Open
05Strong Password PolicyHIGHWordPressWP Dev☐ Open
06Prevent User Enumeration (REST API)MEDIUMWordPress + ServerWP Dev☐ Open
07Remove Version DisclosureMEDIUMWordPress + ServerWP Dev☐ Open
08Secure wp-config.phpCRITICALServer + WordPressSys Admin☐ Open
09Disable Directory ListingMEDIUMServerWeb Admin☐ Open
10File Upload HardeningHIGHServer + WordPressWeb Admin☐ Open
11WAF (ModSecurity + Wordfence)HIGHServer + PluginSys Admin☐ Open
12Security Headers (HSTS, CSP, etc.)HIGHServerWeb Admin☐ Open
13Logging & Monitoring (auditd + WP Activity Log)MEDIUMServer + PluginSys Admin☐ Open
14Backup Security (Encrypted + Offsite)MEDIUMServer + PluginSys Admin☐ Open
Critical: 4 controls
High: 5 controls
Medium: 5 controls
Generated: April 2026 · OWASP WordPress Security Guide