CVE-2023-30616 (published 2023-04-20T18:15Z)

Form block is a wordpress plugin designed to make form creation easier. Versions prior to 1.0.2 are subject to a Cross-Site Request Forgery due to a missing nonce check. There is potential for a Cross Site Request Forgery for all form blocks, since it allows to send requests to the forms from any website without a user noticing. Users are advised to upgrade to version 1.0.2. There are no known workarounds for this vulnerability.

CVSS v3 base score: 6.5, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N)

Weakness information:
cwe_id: CWE-352
cwe_name: Cross-Site Request Forgery (CSRF)
description: The web application does not, or can not, sufficiently verify whether a well-formed, valid, consistent request was intentionally provided by the user who submitted the request.
extended_description: When a web server is designed to receive a request from a client without any mechanism for verifying that it was intentionally sent, then it might be possible for an attacker to trick a client into making an unintentional request to the web server which will be treated as an authentic request. This can be done via a URL, image load, XMLHttpRequest, etc. and can result in exposure of data or unintended code execution.
         

Fix information:
repo_url: https://github.com/epiphyt/form-block
commit_details: repo_url: https://github.com/epiphyt/form-block
author: Matthias Kittsteiner
author_date: 2023-04-20 11:33:39+02:00
msg: 🔒️ Use nonce to prevent CSRF
Closes #6
repository: repo_url: https://github.com/epiphyt/form-block
repo_name: epiphyt/form-block
description: An extensive yet user-friendly form block.
repo_language: PHP
owner: epiphyt
file_changes: filename: form.js
change_type: ModificationType.MODIFY
diff: @@ -6,9 +6,77 @@ document.addEventListener( 'DOMContentLoaded', () => {
 	const forms = document.querySelectorAll( '.wp-block-form-block-form' );
 	
 	for ( const form of forms ) {
+		getNonce( form );
 		form.addEventListener( 'submit', submitForm );
 	}
 	
+	/**
+	 * Get a nonce via Ajax.
+	 * 
+	 * @since	1.0.2
+	 * @param {HTMLElement} form 
+	 */
+	function getNonce( form ) {
+		const formData = new FormData();
+		const xhr = new XMLHttpRequest();
+		
+		formData.set( 'action', 'form-block-create-nonce' );
+		formData.set( 'form_id', form.querySelector( '[name="_form_id"]' ).value );
+		
+		xhr.open( 'POST', formBlockData.ajaxUrl, true );
+		xhr.send( formData );
+		xhr.onreadystatechange = () => {
+			if ( xhr.readyState !== 4 ) {
+				return;
+			}
+			
+			if ( xhr.status === 200 || xhr.status === 201 ) {
+				try {
+					const response = JSON.parse( xhr.responseText );
+					
+					if ( response.success ) {
+						let nonceField = form.querySelector( '[name="_wpnonce"]' );
+						
+						if ( ! nonceField ) {
+							nonceField = document.createElement( 'input' );
+							nonceField.name = '_wpnonce';
+							nonceField.type = 'hidden';
+							
+							form.appendChild( nonceField );
+						}
+						
+						nonceField.value = response?.data?.nonce;
+					}
+					else if ( response?.data?.message ) {
+						// server-side error message
+						setSubmitMessage( form, 'error', response?.data?.message );
+						
+						// disable submit button if nonce creation was not successful
+						const submitButton = form.querySelector( '[type="submit"]' );
+						
+						if ( submitButton ) {
+							submitButton.disabled = true;
+						}
+					}
+					else {
+						// generic error message
+						setSubmitMessage( form, 'error', formBlockData.i18n.backendError );
+					}
+				}
+				catch ( error ) {
+					// invalid data from server
+					setSubmitMessage( form, 'error', formBlockData.i18n.backendError );
+					console.error( error );
+				}
+			}
+			else {
+				// request completely failed
+				setSubmitMessage( form, 'error', formBlockData.i18n.requestError );
+				console.error( xhr.responseText );
+			}
+		}
+	}
+	
 	/**
 	 * Submit the form.
 	 * 
@@ -73,6 +141,9 @@ document.addEventListener( 'DOMContentLoaded', () => {
 						setSubmitMessage( form, 'error', formBlockData.i18n.backendError );
 						console.error( error );
 					}
+					
+					// get a new nonce for another request
+					getNonce( form );
 				}
 				else {
 					// request completely failed

programming_language: JavaScript
filename: class-data.php
change_type: ModificationType.MODIFY
diff: @@ -24,10 +24,40 @@ final class Data {
 	 * Initialize the class.
 	 */
 	public function init(): void {
+		add_action( 'wp_ajax_form-block-create-nonce', [ $this, 'create_nonce' ] );
 		add_action( 'wp_ajax_form-block-submit', [ $this, 'get_request' ] );
+		add_action( 'wp_ajax_nopriv_form-block-create-nonce', [ $this, 'create_nonce' ] );
 		add_action( 'wp_ajax_nopriv_form-block-submit', [ $this, 'get_request' ] );
 	}
 	
+	/**
+	 * Create a nonce via Ajax.
+	 * 
+	 * @since	1.0.2
+	 */
+	public function create_nonce(): void {
+		if ( empty( $_POST['form_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
+			wp_send_json_error(
+				[
+					'message' => __( 'The form could not be prepared to submit requests. Please reload the page.', 'form-block' ),
+				]
+			);
+		}
+		
+		$id = sanitize_text_field( wp_unslash( $_POST['form_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
+		
+		if ( ! $this->is_valid_form_id( $id ) ) {
+			wp_send_json_error();
+		}
+		
+		wp_send_json_success(
+			[
+				'nonce' => wp_create_nonce( 'form_block_submit_' . $id ),
+			],
+			201
+		);
+	}
+	
 	/**
 	 * Get form data.
 	 * 
@@ -93,7 +123,17 @@ final class Data {
 	 * Get the request data.
 	 */
 	public function get_request(): void {
-		if ( ! isset( $_POST['_form_id'] ) || ! isset( $_POST['_town'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
+		if ( empty( $_POST['_wpnonce'] ) ) {
+			/**
+			 * Fires after verifying that the nonce is empty or absent.
+			 */
+			do_action( 'form_block_empty_nonce' );
+			
+			// explicitly return success so that bad actors cannot learn
+			wp_send_json_success();
+		}
+		
+		if ( ! isset( $_POST['_form_id'] ) || ! isset( $_POST['_town'] ) ) {
 			/**
 			 * Fires after a request is considered invalid.
 			 */
@@ -103,6 +143,18 @@ final class Data {
 			wp_send_json_success();
 		}
 		
+		$this->form_id = sanitize_text_field( wp_unslash( $_POST['_form_id'] ) );
+		
+		if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'form_block_submit_' . $this->form_id ) ) {
+			/**
+			 * Fires after a request has an invalid nonce.
+			 */
+			do_action( 'form_block_invalid_nonce' );
+			
+			// explicitly return success so that bad actors cannot learn
+			wp_send_json_success();
+		}
+		
 		if ( $this->is_honeypot_filled() ) {
 			/**
 			 * Fires after a request is considered invalid due to a filled honeypot.
@@ -113,8 +165,6 @@ final class Data {
 			wp_send_json_success();
 		}
 		
-		$this->form_id = sanitize_text_field( wp_unslash( $_POST['_form_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
-		
 		/**
 		 * Fires before data has been validated.
 		 * 
@@ -196,6 +246,20 @@ final class Data {
 		return $is_filled;
 	}
 	
+	/**
+	 * Check wheter a form ID is valid. That means, there are form fields stored.
+	 * 
+	 * @since	1.0.2
+	 * 
+	 * @param	string	$form_id The form ID to check
+	 * @return	bool Weter a form ID is valid
+	 */
+	public function is_valid_form_id( string $form_id ): bool {
+		$maybe_data = (array) get_option( 'form_block_data_' . $form_id, [] );
+		
+		return ! empty( $maybe_data['fields'] );
+	}
+	
 	/**
 	 * Send form submission to the recipients.
 	 * 

programming_language: PHP
filename: class-validation.php
change_type: ModificationType.MODIFY
diff: @@ -16,6 +16,17 @@ final class Validation {
 	 */
 	public static $instance;
 	
+	/**
+	 * @since	1.0.2
+	 * @var		array List of field names used by the system
+	 */
+	private $system_field_names = [
+		'_form_id',
+		'_town',
+		'_wpnonce',
+		'action',
+	];
+	
 	/**
 	 * Validate form fields by allowed names.
 	 *
@@ -128,11 +139,7 @@ final class Validation {
 	private function get_allowed_names( array $form_data ): array {
 		Form_Block::get_instance()->reset_block_name_attributes();
 		
-		$allowed_names = [
-			'_form_id',
-			'_town',
-			'action',
-		];
+		$allowed_names = $this->system_field_names;
 		
 		foreach ( $form_data['fields'] as $field ) {
 			$field_name = Form_Block::get_instance()->get_block_name_attribute( $field );
@@ -195,7 +202,9 @@ final class Validation {
 		}
 		// phpcs:enable
 		
-		unset( $validated['_form_id'], $validated['action'], $validated['_town'] );
+		foreach ( $this->system_field_names as $name ) {
+			unset( $validated[ $name ] );
+		}
 		
 		// remove empty fields
 		foreach ( $validated as $key => $value ) {

programming_language: PHP