The Exploit
The attacker needs no WordPress authentication; they only need to send a POST to the token endpoint with a Referer header pointing at any page that contains the Ninja Forms Submissions Table block.
curl -i -s -X POST 'https://TARGET/wp-json/ninja-forms-views/v1/token' \
-H 'Content-Type: application/json' \
-H 'Referer: https://TARGET/page-with-submissions-table' \
-d '{"formIds":[123]}'
The response returns a bearer token scoped to the requested form ID:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"publicKey":"X5b3Z...",
"expiresIn":900,
"formIds":[123]
}
That token can then be reused against Ninja Forms view endpoints to read form definitions and submission records for form 123 without any login.
What the Patch Did
Before:
$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
if (empty($formIds)) {
return new WP_Error(
'invalid_form_ids',
__('No valid form IDs provided', 'ninja-forms'),
array('status' => 400)
);
}
// 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,
);
After:
// REFACTOR: 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. Please use formID parameter instead.', 'ninja-forms'),
array('status' => 400)
);
}
$formId = $formIds[0];
}
}
// Sanitize and validate form ID
$formId = absint($formId);
if (!$formId) {
return new WP_Error(
'invalid_form_id',
__('Valid form ID is required', 'ninja-forms'),
array('status' => 400)
);
}
// FIX: Validate that the form exists and is accessible
$form = Ninja_Forms()->form( $formId )->get();
if (!$form) {
return new WP_Error(
'form_not_found',
__('The requested form does not exist', 'ninja-forms'),
array('status' => 404)
);
}
// FIX: Validate that user has permission to access this form
// This prevents users from generating tokens for arbitrary forms
$referer = wp_get_referer();
if (!$referer) {
return new WP_Error(
'invalid_request',
__('Request must come from a valid page with submissions table block', 'ninja-forms'),
array('status' => 403)
);
}
// Parse the referring page to validate block authorization
$post_id = url_to_postid($referer);
if (!$post_id) {
// Handle front page, archives, etc.
$parsed_url = parse_url($referer);
if ($parsed_url['path'] === '/' || $parsed_url['path'] === home_url('/')) {
$post_id = get_option('page_on_front')
...
The patch adds an authorization gate: it now verifies the requested formID exists and that the request originates from a valid page with the Submissions Table block via wp_get_referer() / url_to_postid().
Root Cause
This was a broken access control bug (CWE-639/CWE-862): the REST route accepted attacker-controlled formIds from the request body and immediately generated a bearer token with TokenFactory::create() without checking whether the caller was entitled to request access to that form. The attacker-controlled data flowed from the POST body into token issuance, crossing the trust boundary between unauthenticated HTTP input and privileged form access.
Why It Works
The load-bearing change is the authorization check on the request origin: without the wp_get_referer()/page-validation guard, the endpoint would still mint tokens for arbitrary form IDs. The form = Ninja_Forms()->form($formId)->get() check is secondary hygiene: it prevents tokens being issued for nonexistent forms and ensures the form is canonical. The rest of the patch is input normalization and error handling, including legacy support for formIds and absint() sanitization.
Hardening Checklist
- register REST routes with a proper
permission_callbackinstead of relying on header-based heuristics. - validate resource IDs with
absint()and existence checks via application APIs likeNinja_Forms()->form($formId)->get(). - avoid using attacker-controlled arrays as direct token scopes; normalize to a single
formIDif the endpoint is supposed to issue one token. - use
wp_get_referer()andurl_to_postid()withhas_block()or equivalent block inspection only as a secondary safety net. - require
wp_verify_nonce()or user capability checks for any endpoint that mints authentication tokens or exposes submission data.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-11924