CVE-2023-30616: Cross-Site Request Forgery (CSRF) (CWE-352)
value: 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.
CWE 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.
CVSS v3 base score: 6.5
Published: 2023-04-20T18:15Z
Repository: https://github.com/epiphyt/form-block
Language: PHP
Files changed: assets/js/form.js, inc/form-data/class-data.php, inc/form-data/class-validation.php
Commit date: 2023-04-20 11:33:39 +0200
Commit message: 🔒️ Use nonce to prevent CSRF
Closes #6

Diff: diff --git a/assets/js/form.js b/assets/js/form.js
index 24449f5..ca66461 100644
--- a/assets/js/form.js
+++ b/assets/js/form.js
@@ -4,13 +4,81 @@
 
 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.
 	 * 
 	 * @param	{Event}	event The submit event
 	 */
@@ -71,10 +139,13 @@ document.addEventListener( 'DOMContentLoaded', () => {
 					catch ( error ) {
 						// invalid data from server
 						setSubmitMessage( form, 'error', formBlockData.i18n.backendError );
 						console.error( error );
 					}
+					
+					// get a new nonce for another request
+					getNonce( form );
 				}
 				else {
 					// request completely failed
 					setSubmitMessage( form, 'error', formBlockData.i18n.requestError );
 					console.error( xhr.responseText );
diff --git a/inc/form-data/class-data.php b/inc/form-data/class-data.php
index 70abf78..c8ab04e 100644
--- a/inc/form-data/class-data.php
+++ b/inc/form-data/class-data.php
@@ -22,14 +22,44 @@ 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.
 	 * 
 	 * @param	string	$form_id Current form ID
 	 * @return	array Form data
@@ -91,32 +121,52 @@ 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.
 			 */
 			do_action( 'form_block_invalid_data' );
 			
 			// explicitly return success so that bots cannot learn
 			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.
 			 */
 			do_action( 'form_block_is_honeypot_filled' );
 			
 			// explicitly return success so that bots cannot learn
 			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.
 		 * 
 		 * @param	string	$form_id The form ID
 		 */
@@ -194,10 +244,24 @@ final class Data {
 		$is_filled = apply_filters( 'form_block_is_honeypot_filled', $is_filled );
 		
 		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.
 	 * 
 	 * @param	array	$fields The validated fields
 	 * @param	array	$files The validated files
diff --git a/inc/form-data/class-validation.php b/inc/form-data/class-validation.php
index f3c5165..e9fc965 100644
--- a/inc/form-data/class-validation.php
+++ b/inc/form-data/class-validation.php
@@ -14,10 +14,21 @@ final class Validation {
 	/**
 	 * @var		\epiphyt\Form_Block\form_data\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.
 	 *
 	 * @param	string	$name The field name
 	 * @param	array	$form_data The form data
@@ -126,15 +137,11 @@ final class Validation {
 	 * @return	array List of allowed name attributes
 	 */
 	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 );
 			$allowed_names[] = preg_replace( '/-\d+$/', '', $field_name );
 		}
@@ -193,11 +200,13 @@ final class Validation {
 			
 			$validated[ $key ] = $value;
 		}
 		// 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 ) {
 			if ( ! empty( $value ) ) {
 				continue;

Vulnerable code: 		if ( ! isset( $_POST['_form_id'] ) || ! isset( $_POST['_town'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
		$this->form_id = sanitize_text_field( wp_unslash( $_POST['_form_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
		
		$allowed_names = [
			'_form_id',
			'_town',
			'action',
		];
		unset( $validated['_form_id'], $validated['action'], $validated['_town'] );
Fixed code: 		getNonce( form );
	/**
	 * 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 );
			}
		}
	}
	
					
					// get a new nonce for another request
					getNonce( form );
		add_action( 'wp_ajax_form-block-create-nonce', [ $this, 'create_nonce' ] );
		add_action( 'wp_ajax_nopriv_form-block-create-nonce', [ $this, 'create_nonce' ] );
	/**
	 * 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
		);
	}
	
		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'] ) ) {
		$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();
		}
		
	/**
	 * 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'] );
	}
	
	/**
	 * @since	1.0.2
	 * @var		array List of field names used by the system
	 */
	private $system_field_names = [
		'_form_id',
		'_town',
		'_wpnonce',
		'action',
	];
	
		$allowed_names = $this->system_field_names;
		foreach ( $this->system_field_names as $name ) {
			unset( $validated[ $name ] );
		}