| @@ -13,8 +13,42 @@ | ||
| 13 | 13 | } |
| 14 | 14 | |
| 15 | 15 | class FormBuilderController { |
| 16 | 16 | |
| 17 | + /** | |
| 18 | + * Verify the REST request nonce to protect the public form endpoints | |
| 19 | + * against CSRF. | |
| 20 | + * | |
| 21 | + * The frontend sends the standard WordPress REST nonce via the | |
| 22 | + * `X-WP-Nonce` header (see `ABlocksGlobal.nonce`, generated with | |
| 23 | + * `wp_create_nonce( 'wp_rest' )`). | |
| 24 | + * | |
| 25 | + * @param WP_REST_Request $request | |
| 26 | + * | |
| 27 | + * @return true|WP_REST_Response True when valid, error response otherwise. | |
| 28 | + */ | |
| 29 | + private function verify_nonce( WP_REST_Request $request ) { | |
| 30 | + $nonce = $request->get_header( 'X-WP-Nonce' ); | |
| 31 | + | |
| 32 | + if ( empty( $nonce ) ) { | |
| 33 | + $nonce = $request->get_param( 'security' ); | |
| 34 | + } | |
| 35 | + | |
| 36 | + if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { | |
| 37 | + return new WP_REST_Response( | |
| 38 | + [ | |
| 39 | + 'success' => false, | |
| 40 | + 'data' => [ | |
| 41 | + 'message' => __( 'Security check failed. Please reload the page and try again.', 'ablocks' ), | |
| 42 | + ], | |
| 43 | + ], | |
| 44 | + 403 | |
| 45 | + ); | |
| 46 | + } | |
| 47 | + | |
| 48 | + return true; | |
| 49 | + } | |
| 50 | + | |
| 17 | 51 | public function register_routes() { |
| 18 | 52 | |
| 19 | 53 | register_rest_route( |
| 20 | 54 | ABLOCKS_REST_NAMESPACE, |
| @@ -107,8 +141,13 @@ | ||
| 107 | 141 | } |
| 108 | 142 | |
| 109 | 143 | public function login( WP_REST_Request $request ) { |
| 110 | 144 | |
| 145 | + $nonce_check = $this->verify_nonce( $request ); | |
| 146 | + if ( true !== $nonce_check ) { | |
| 147 | + return $nonce_check; | |
| 148 | + } | |
| 149 | + | |
| 111 | 150 | $params = $request->get_params(); |
| 112 | 151 | |
| 113 | 152 | $block_data = Helper::get_block_attributes( |
| 114 | 153 | $params['current_post_id'], |
| @@ -172,12 +211,32 @@ | ||
| 172 | 211 | } |
| 173 | 212 | |
| 174 | 213 | public function register( WP_REST_Request $request ) { |
| 175 | 214 | |
| 215 | + $nonce_check = $this->verify_nonce( $request ); | |
| 216 | + if ( true !== $nonce_check ) { | |
| 217 | + return $nonce_check; | |
| 218 | + } | |
| 219 | + | |
| 176 | 220 | $params = $request->get_params(); |
| 177 | 221 | |
| 222 | + $post_id = $params['current_post_id']; | |
| 223 | + | |
| 224 | + if ( is_numeric( $post_id ) && | |
| 225 | + ! current_user_can( 'edit_post', $post_id ) && | |
| 226 | + get_post_status( $post_id ) !== 'publish' | |
| 227 | + ) { | |
| 228 | + return new WP_REST_Response( | |
| 229 | + [ | |
| 230 | + 'success' => false, | |
| 231 | + 'data' => [ 'message' => __( 'Invalid post.', 'ablocks' ) ], | |
| 232 | + ], | |
| 233 | + 400 | |
| 234 | + ); | |
| 235 | + } | |
| 236 | + | |
| 178 | 237 | $block_data = Helper::get_block_attributes( |
| 179 | - $params['current_post_id'], | |
| 238 | + $post_id, | |
| 180 | 239 | $params['block_id'], |
| 181 | 240 | 'ablocks/form-builder' |
| 182 | 241 | ); |
| 183 | 242 | |
| @@ -287,9 +346,10 @@ | ||
| 287 | 346 | |
| 288 | 347 | if ( |
| 289 | 348 | ! empty( $role ) && |
| 290 | 349 | strtolower( $role ) !== 'default' && |
| 291 | - array_key_exists( $role, wp_roles()->roles ) | |
| 350 | + array_key_exists( $role, wp_roles()->roles ) && | |
| 351 | + $this->is_safe_registration_role( $role ) | |
| 292 | 352 | ) { |
| 293 | 353 | ( new \WP_User( $user_id ) )->set_role( $role ); |
| 294 | 354 | } |
| 295 | 355 | |
| @@ -328,8 +388,13 @@ | ||
| 328 | 388 | } |
| 329 | 389 | |
| 330 | 390 | public function forget_password( WP_REST_Request $request ) { |
| 331 | 391 | |
| 392 | + $nonce_check = $this->verify_nonce( $request ); | |
| 393 | + if ( true !== $nonce_check ) { | |
| 394 | + return $nonce_check; | |
| 395 | + } | |
| 396 | + | |
| 332 | 397 | $params = $request->get_params(); |
| 333 | 398 | |
| 334 | 399 | $block_data = Helper::get_block_attributes( |
| 335 | 400 | $params['current_post_id'], |
| @@ -386,44 +451,16 @@ | ||
| 386 | 451 | 400 |
| 387 | 452 | ); |
| 388 | 453 | } |
| 389 | 454 | |
| 390 | - if ( ! email_exists( $params['email'] ) ) { | |
| 391 | - return new WP_REST_Response( | |
| 392 | - [ | |
| 393 | - 'success' => false, | |
| 394 | - 'data' => $this->prepare_res( | |
| 395 | - [ 'message' => __( 'This email does not exist', 'ablocks' ) ], | |
| 396 | - $block_data, | |
| 397 | - $redirect_url | |
| 398 | - ), | |
| 399 | - ], | |
| 400 | - 400 | |
| 401 | - ); | |
| 402 | - } | |
| 403 | - | |
| 404 | - $result = retrieve_password( $params['email'] ); | |
| 405 | - | |
| 406 | - if ( is_wp_error( $result ) ) { | |
| 407 | - return new WP_REST_Response( | |
| 408 | - [ | |
| 409 | - 'success' => false, | |
| 410 | - 'data' => $this->prepare_res( | |
| 411 | - [ 'message' => esc_html( $result->get_error_message() ) ], | |
| 412 | - $block_data, | |
| 413 | - $redirect_url | |
| 414 | - ), | |
| 415 | - ], | |
| 416 | - 400 | |
| 417 | - ); | |
| 418 | - } | |
| 419 | - | |
| 420 | - return new WP_REST_Response( | |
| 455 | + // Generic response used whether or not the account exists, to avoid | |
| 456 | + // leaking which emails are registered (user enumeration). | |
| 457 | + $generic_response = new WP_REST_Response( | |
| 421 | 458 | [ |
| 422 | 459 | 'success' => true, |
| 423 | 460 | 'data' => $this->prepare_res( |
| 424 | 461 | [ |
| 425 | - 'message' => __( 'Password reset email is sent', 'ablocks' ), | |
| 462 | + 'message' => __( 'If an account exists for that email, a password reset link has been sent.', 'ablocks' ), | |
| 426 | 463 | ], |
| 427 | 464 | $block_data, |
| 428 | 465 | $redirect_url |
| 429 | 466 | ), |
| @@ -429,13 +466,28 @@ | ||
| 429 | 466 | ), |
| 430 | 467 | ], |
| 431 | 468 | 200 |
| 432 | 469 | ); |
| 470 | + | |
| 471 | + if ( ! email_exists( $params['email'] ) ) { | |
| 472 | + return $generic_response; | |
| 473 | + } | |
| 474 | + | |
| 475 | + // Ignore the result: a failure (e.g. an invalid user) must not reveal | |
| 476 | + // account existence, so we still return the generic response. | |
| 477 | + retrieve_password( $params['email'] ); | |
| 478 | + | |
| 479 | + return $generic_response; | |
| 433 | 480 | } |
| 434 | 481 | |
| 435 | 482 | |
| 436 | 483 | public function submit( WP_REST_Request $request ) { |
| 437 | 484 | |
| 485 | + $nonce_check = $this->verify_nonce( $request ); | |
| 486 | + if ( true !== $nonce_check ) { | |
| 487 | + return $nonce_check; | |
| 488 | + } | |
| 489 | + | |
| 438 | 490 | $params = $request->get_params(); |
| 439 | 491 | |
| 440 | 492 | $block_data = Helper::get_block_attributes( |
| 441 | 493 | $params['current_post_id'], |
| @@ -479,8 +531,19 @@ | ||
| 479 | 531 | } elseif ( $validate->has_message() ) { |
| 480 | 532 | $output['confirmationNotice'] = $validate->apply_vars( $block_data['parentAttributes']['confirmationNotice'] ?? __( 'Form successfully submitted!', 'ablocks' ) ); |
| 481 | 533 | $output['message'] = $validate->get_message(); |
| 482 | 534 | |
| 535 | + /** | |
| 536 | + * Fires after a form-builder submission has been validated and processed | |
| 537 | + * successfully. Third-party automations (e.g. Zaplane) can hook this to | |
| 538 | + * react to submissions. | |
| 539 | + * | |
| 540 | + * @param array $form_info { 'info' => [ type, postId, email, actions, config ], 'data' => [ field => [ 'value' => mixed ] ] }. | |
| 541 | + * @param array $block_data Resolved form block attributes/inner blocks. | |
| 542 | + * @param ValidateFormData $validate The validation object ( state_data holds submission_id ). | |
| 543 | + */ | |
| 544 | + do_action( 'ablocks/form_builder/after_submission', $validate->form_info, $block_data, $validate ); | |
| 545 | + | |
| 483 | 546 | wp_send_json_success( $output ); |
| 484 | 547 | } |
| 485 | 548 | |
| 486 | 549 | wp_send_json_error( [ 'message' => __( 'Action is not defined.', 'ablocks' ) ] ); |
| @@ -617,8 +680,41 @@ | ||
| 617 | 680 | ], |
| 618 | 681 | ]; |
| 619 | 682 | } |
| 620 | 683 | |
| 684 | + | |
| 685 | + private function is_safe_registration_role( string $role ) : bool { | |
| 686 | + $role_obj = get_role( $role ); | |
| 687 | + if ( ! $role_obj ) { | |
| 688 | + return false; | |
| 689 | + } | |
| 690 | + $privileged_caps = [ | |
| 691 | + 'manage_options', | |
| 692 | + 'edit_users', | |
| 693 | + 'delete_users', | |
| 694 | + 'create_users', | |
| 695 | + 'promote_users', | |
| 696 | + 'edit_theme_options', | |
| 697 | + ]; | |
| 698 | + foreach ( $privileged_caps as $cap ) { | |
| 699 | + if ( ! empty( $role_obj->capabilities[ $cap ] ) ) { | |
| 700 | + return false; | |
| 701 | + } | |
| 702 | + } | |
| 703 | + | |
| 704 | + // The checks above read the role's stored capabilities, which never | |
| 705 | + // include anything the permission map grants — those are added per | |
| 706 | + // request and are invisible here. A role configured for the Site Editor | |
| 707 | + // would sail through, so a self-registration form could hand a visitor | |
| 708 | + // edit_theme_options. Ask the permission map directly. | |
| 709 | + $bridged = \ABlocks\Permissions\Caps::native_bridge(); | |
| 710 | + $grants = \ABlocks\Permissions::get_role_grants( $role ); | |
| 711 | + if ( array_intersect( array_keys( $bridged ), $grants ) ) { | |
| 712 | + return false; | |
| 713 | + } | |
| 714 | + | |
| 715 | + return true; | |
| 716 | + } | |
| 621 | 717 | |
| 622 | 718 | private function submit_schema() { |
| 623 | 719 | return [ |
| 624 | 720 | 'current_post_id' => [ |