Skip to content

Latest commit

 

History

History
481 lines (366 loc) · 10.8 KB

File metadata and controls

481 lines (366 loc) · 10.8 KB

Nonce Validation - CSRF Protection

The Problem

Cross-Site Request Forgery (CSRF) attacks trick users into performing unwanted actions on your site:

  • Attacker creates malicious link or form
  • User clicks while logged into your site
  • Unwanted action executes with user's permissions
  • User's account compromised, data deleted, or settings changed

Without nonce validation, attackers can make users do anything they have permission to do.

What is a Nonce?

Nonce = "Number used ONCE"

In WordPress, nonces are:

  • Temporary tokens tied to user, action, and time
  • Valid for 24 hours (12-24 hour window)
  • Prevent CSRF attacks
  • Verify requests originated from your site

Bad Practice (bad.php)

No Nonce Validation - CRITICAL VULNERABILITY!

function handle_delete_post() {
    if ( isset( $_POST['post_id'])) {
        wp_delete_post( $_POST['post_id'], true);
    }
}
add_action( 'admin_post_delete_post', 'handle_delete_post');

Attack Example: Attacker creates malicious page:

<form method="post" action="https://yoursite.com/wp-admin/admin-post.php">
    <input type="hidden" name="action" value="delete_post">
    <input type="hidden" name="post_id" value="123">
</form>
<script>document.forms[0].submit();</script>

When logged-in admin visits this page, post #123 is deleted automatically!

More Attack Examples

// Delete all posts
<img src="https://yoursite.com/wp-admin/?action=delete_all&user_id=1">

// Change admin password
<form action="https://yoursite.com/wp-admin/profile.php" method="post">
    <input name="pass1" value="hacked123">
    <input name="pass2" value="hacked123">
</form>

// Install malicious plugin
// Transfer money
// Delete users
// ... anything the user can do!

Good Practice (good.php)

Solution 1: Always Validate Nonces

function handle_delete_post(): void {
    // CHECK NONCE FIRST!
    if ( !isset( $_POST['_wpnonce']) || !wp_verify_nonce( $_POST['_wpnonce'], 'delete_post_action')) {
        wp_die( 'Security check failed');
    }
    
    // Check permissions
    if ( !current_user_can( 'delete_posts')) {
        wp_die( 'Permission denied');
    }
    
    // Now safe to process
    wp_delete_post( $_POST['post_id'], true);
}

Solution 2: Generate Nonce in Forms

<form method="post" action="<?php echo admin_url( 'admin-post.php'); ?>">
    <input type="hidden" name="action" value="delete_post">
    <input type="hidden" name="post_id" value="123">
    
    <?php wp_nonce_field( 'delete_post_action'); ?>
    
    <button type="submit">Delete</button>
</form>

wp_nonce_field() generates:

<input type="hidden" name="_wpnonce" value="a1b2c3d4e5">
<input type="hidden" name="_wp_http_referer" value="/wp-admin/...">

Solution 3: URL-Based Nonces

// Generate nonce URL
$delete_url = wp_nonce_url(
    admin_url( 'admin-post.php?action=delete_post&post_id=123'),
    'delete_post_123',
    'delete_nonce'
);

// Result: /wp-admin/admin-post.php?action=delete_post&post_id=123&delete_nonce=a1b2c3d4

// Verify
if ( !wp_verify_nonce( $_GET['delete_nonce'], 'delete_post_123')) {
    wp_die( 'Invalid nonce');
}

Solution 4: AJAX Nonces

// Generate nonce for JavaScript
<button 
    data-nonce="<?php echo wp_create_nonce( 'update_status'); ?>"
    data-post-id="123">
    Update
</button>

<script>
jQuery.ajax( {
    url: ajaxurl,
    method: 'POST',
    data: {
        action: 'update_status',
        post_id: 123,
        _ajax_nonce: nonce
    }
});
</script>

// Verify in PHP
function handle_ajax() {
    if ( !wp_verify_nonce($_POST['_ajax_nonce'], 'update_status')) {
        wp_send_json_error('Invalid nonce', 403);
    }
    
    // Process...
}

Core Nonce Functions

wp_nonce_field()

wp_nonce_field($action, $name, $referer, $echo);

// Example
wp_nonce_field('save_settings', 'my_nonce');
// Generates hidden input fields

wp_create_nonce()

$nonce = wp_create_nonce($action);

// Example
$nonce = wp_create_nonce('delete_post_123');
// Returns: 'a1b2c3d4e5'

wp_verify_nonce()

$result = wp_verify_nonce($nonce, $action);

// Returns:
// false = invalid/expired
// 1 = valid (0-12 hours old)
// 2 = valid (12-24 hours old)

// Example
if (wp_verify_nonce($_POST['_wpnonce'], 'my_action') === false) {
    wp_die('Invalid nonce');
}

check_admin_referer()

check_admin_referer($action, $name);

// Dies automatically if nonce invalid
// Shorthand for verify + die

// Example
check_admin_referer('save_settings', '_wpnonce');
// If invalid, dies with error

wp_nonce_url()

$url = wp_nonce_url($bare_url, $action, $name);

// Example
$url = wp_nonce_url(
    admin_url('admin-post.php?action=delete'),
    'delete_post_123',
    'nonce'
);
// Result: ...?action=delete&nonce=a1b2c3d4

Best Practices

1. Action Names Should Be Specific

// BAD: Too generic
wp_create_nonce('action');
wp_create_nonce('form');
wp_create_nonce('save');

// GOOD: Specific and descriptive
wp_create_nonce('delete_post_123');
wp_create_nonce('save_user_settings_456');
wp_create_nonce('update_plugin_options');

2. Include Item ID When Relevant

// BAD: Same nonce for all posts
wp_create_nonce('delete_post');

// GOOD: Unique nonce per post
wp_create_nonce('delete_post_' . $post_id);

This ensures nonce for post #1 can't be used to delete post #2!

3. Always Check Permissions Too

// Nonce alone is NOT enough!
if (!wp_verify_nonce($_POST['nonce'], 'delete_post')) {
    wp_die('Invalid nonce');
}

// MUST also check permissions
if (!current_user_can('delete_posts')) {
    wp_die('Permission denied');
}

// Now safe
wp_delete_post($post_id);

Nonce verifies the request came from your site.
Permission check verifies the user is allowed to do the action.

4. Use POST for State-Changing Actions

// BAD: GET request to delete
<a href="?action=delete&id=123">Delete</a>

// GOOD: POST request with form
<form method="post">
    <?php wp_nonce_field('delete_123'); ?>
    <button type="submit">Delete</button>
</form>

GET requests should be read-only. DELETE/UPDATE must use POST.

5. Handle Nonce Expiration Gracefully

$result = wp_verify_nonce($_POST['nonce'], 'long_form');

if ($result === false) {
    // Expired or invalid
    wp_die('Security check failed. Please refresh the page and try again.');
}

if ($result === 2) {
    // Valid but aging (12-24 hours old)
    // Consider logging or warning
    error_log('Aging nonce used by user ' . get_current_user_id());
}

// Process form...

Common Mistakes

Mistake 1: Creating Nonce but Not Verifying

// BAD: Nonce in form but never checked!
function handle_form() {
    update_option('setting', $_POST['value']); // No check!
}

Always verify!

Mistake 2: Wrong Action Name

// Form generates with action 'save_settings'
wp_nonce_field('save_settings');

// But handler verifies 'update_settings' - MISMATCH!
wp_verify_nonce($_POST['_wpnonce'], 'update_settings'); // Always fails!

Action names must match exactly!

Mistake 3: Not Checking Return Value

// BAD: Calling verify but not checking result
wp_verify_nonce($_POST['nonce'], 'action');
update_option('value', $_POST['value']); // Runs regardless!

// GOOD: Check the result
if (!wp_verify_nonce($_POST['nonce'], 'action')) {
    wp_die('Invalid nonce');
}
update_option('value', $_POST['value']);

Mistake 4: Nonce Without Permission Check

// BAD: Valid nonce but no permission check
if (wp_verify_nonce($_POST['nonce'], 'admin_action')) {
    delete_option('critical_setting'); // Any user with nonce can do this!
}

// GOOD: Both nonce AND permission
if (wp_verify_nonce($_POST['nonce'], 'admin_action') && current_user_can('manage_options')) {
    delete_option('critical_setting');
}

REST API Considerations

WordPress REST API has built-in nonce handling:

// REST API automatically checks nonces for authenticated requests
register_rest_route('myplugin/v1', '/posts', [
    'methods' => 'POST',
    'callback' => 'create_post',
    'permission_callback' => function() {
        return current_user_can('edit_posts');
    }
]);

For non-REST clients, add nonce to request:

fetch('/wp-json/myplugin/v1/posts', {
    method: 'POST',
    headers: {
        'X-WP-Nonce': wpApiSettings.nonce
    },
    body: JSON.stringify({...})
});

Testing CSRF Protection

Test Cases

// 1. Try without nonce
// Should: Fail with error

// 2. Try with invalid nonce
$_POST['_wpnonce'] = 'invalid123';
// Should: Fail with error

// 3. Try with expired nonce
$_POST['_wpnonce'] = wp_create_nonce('action'); 
// Wait 25 hours
// Should: Fail with error

// 4. Try with valid nonce
$_POST['_wpnonce'] = wp_create_nonce('action');
// Should: Succeed

Automated Testing

public function test_nonce_required() {
    $_POST['action'] = 'delete_post';
    $_POST['post_id'] = 123;
    // No nonce
    
    $this->expectException(WPDieException::class);
    handle_delete_post();
}

public function test_invalid_nonce_rejected() {
    $_POST['_wpnonce'] = 'invalid';
    $_POST['action'] = 'delete_post';
    
    $this->expectException(WPDieException::class);
    handle_delete_post();
}

public function test_valid_nonce_accepted() {
    $_POST['_wpnonce'] = wp_create_nonce('delete_post');
    $_POST['post_id'] = 123;
    
    // Should not throw
    handle_delete_post();
}

Key Takeaways

ALWAYS validate nonces for state-changing actions
Use wp_nonce_field() in forms
Use wp_nonce_url() for links
Use wp_create_nonce() for AJAX
Check permissions in addition to nonce
Use specific action names (include ID when relevant)
Use POST for delete/update operations
Handle expiration gracefully with helpful messages

NEVER trust requests without nonce validation
DON'T use generic action names
DON'T skip permission checks
DON'T use GET for state-changing actions
DON'T create nonce but forget to verify
DON'T reuse nonces across different actions

The Bottom Line

CSRF attacks are easy to execute and devastating.

Without nonces, an attacker can:

  • Delete all your content
  • Change passwords
  • Install malicious plugins
  • Transfer money
  • Steal data

One missing nonce check = complete account takeover.

Always use nonces:

// Form: Generate nonce
<?php wp_nonce_field('my_action'); ?>

// Handler: Verify nonce
if (!wp_verify_nonce($_POST['_wpnonce'], 'my_action')) {
    wp_die('Security check failed');
}

// Also check permissions!
if (!current_user_can('required_capability')) {
    wp_die('Permission denied');
}

// Now safe to process

Two lines of code prevent catastrophic security breaches.

There is NO excuse for missing nonce validation.