diff --git a/docs/code-reference/META.md b/docs/code-reference/META.md index 4334dc7f..dab70558 100644 --- a/docs/code-reference/META.md +++ b/docs/code-reference/META.md @@ -39,6 +39,7 @@ This file tracks code elements that need documentation. - `acf/bidirectional/supported_field_types_for_post` - `acf/bidirectional/supported_target_field_types` +- `scf/bidirectional/allow_target` ## acf-field-functions.php @@ -350,6 +351,7 @@ This file tracks code elements that need documentation. - `acf/validate_post_id` - `acf/validate_setting` - `acf/verify_ajax` +- `scf/upload/generate_previews` ## api/api-template.php diff --git a/docs/code-reference/acf-bidirectional-functions-file.md b/docs/code-reference/acf-bidirectional-functions-file.md index e9cfb6f2..26316f1c 100644 --- a/docs/code-reference/acf-bidirectional-functions-file.md +++ b/docs/code-reference/acf-bidirectional-functions-file.md @@ -11,6 +11,16 @@ Process updating bidirectional fields. * @param string|false $target_prefix The ACF prefix for a post, user or term ID required for the update_field call for this field type. * @return void +## `_scf_is_allowed_bidirectional_target()` + +Check whether a submitted bidirectional target is a valid choice for the origin field. + +* @since SCF 6.9.7 +@internal +* @param integer|string $target ACF encoded target ID (e.g. 123, "user_1" or "term_5"). +* @param array $field The origin field. +* @return boolean + ## `_scf_prepare_bidirectional_update()` Prepare a side-effect-free bidirectional update plan. diff --git a/docs/code-reference/api/api-helpers-file.md b/docs/code-reference/api/api-helpers-file.md index cd24e2d5..b8106402 100644 --- a/docs/code-reference/api/api-helpers-file.md +++ b/docs/code-reference/api/api-helpers-file.md @@ -591,6 +591,15 @@ acf_get_valid_post_id * @param $post_id (mixed) * @return $post_id (mixed) +## `scf_post_password_required()` + +Checks whether field values stored on an ACF post ID belong to a password-protected +post whose password has not been entered yet. + +* @since SCF 6.9.7 +* @param integer|string $post_id ACF encoded post ID. +* @return boolean + ## `acf_get_post_id_info()` acf_get_post_id_info diff --git a/docs/code-reference/rest-api/class-acf-rest-api-file.md b/docs/code-reference/rest-api/class-acf-rest-api-file.md index 4db98fb9..ec8988d0 100644 --- a/docs/code-reference/rest-api/class-acf-rest-api-file.md +++ b/docs/code-reference/rest-api/class-acf-rest-api-file.md @@ -128,6 +128,16 @@ and `sanitize_callback` for nested schema properties, see: * @param string $param * @return boolean|WP_Error +### `is_password_protected_content_hidden` + +Checks whether a post's field values must stay out of the response because the +post is password protected and the caller has neither unlocked it nor can edit it. + +* @since SCF 6.9.7 +* @param integer $post_id The post ID. +* @param WP_REST_Request $request The current request. +* @return boolean + ### `load_fields` Load field values into the requested object. This method is not a part of any public API and is only public as diff --git a/includes/Blocks/Bindings.php b/includes/Blocks/Bindings.php index a95aecce..e83a1034 100644 --- a/includes/Blocks/Bindings.php +++ b/includes/Blocks/Bindings.php @@ -57,7 +57,12 @@ public function get_value( array $source_attrs, \WP_Block $block_instance, strin if ( ! isset( $source_attrs['key'] ) || ! is_string( $source_attrs['key'] ) ) { $value = ''; } else { - $field = get_field_object( $source_attrs['key'], false, true, true, true ); + $post_id = acf_get_valid_post_id( false ); + if ( scf_post_password_required( $post_id ) ) { + return ''; + } + + $field = get_field_object( $source_attrs['key'], $post_id, true, true, true ); if ( ! $field ) { return ''; @@ -79,7 +84,13 @@ public function get_value( array $source_attrs, \WP_Block $block_instance, strin } } - $field_value = $field['value']; + $raw_value = acf_get_value( $post_id, $field ); + $field_value = scf_rest_sanitize_user_data( $field['value'], $raw_value, $field ); + + add_filter( 'acf/rest/expose_reference_target', array( $this, 'expose_reference_target' ), PHP_INT_MAX, 2 ); + $field_value = acf_rest_sanitize_reference_data( $field_value, $raw_value, $field ); + remove_filter( 'acf/rest/expose_reference_target', array( $this, 'expose_reference_target' ), PHP_INT_MAX ); + $field_value = $this->strip_post_passwords( $field_value ); switch ( $attribute_name ) { case 'id': @@ -122,4 +133,61 @@ public function get_value( array $source_attrs, \WP_Block $block_instance, strin return apply_filters( 'acf/blocks/binding_value', $value, $source_attrs, $block_instance, $attribute_name ); } + + /** + * Removes the password from expanded post objects before they are output. + * + * @since SCF 6.9.7 + * + * @param mixed $value The field value, possibly containing WP_Post objects. + * @return mixed + */ + private function strip_post_passwords( $value ) { + if ( $value instanceof \WP_Post ) { + $value = clone $value; + $value->post_password = ''; + return $value; + } + + if ( is_array( $value ) ) { + foreach ( $value as $key => $item ) { + $value[ $key ] = $this->strip_post_passwords( $item ); + } + } + + return $value; + } + + /** + * Decides whether a referenced post is expanded in a block binding value. + * + * Bindings render on the front end, so a referenced post is exposed when the + * viewer can read it there rather than when the REST API would return it. + * Attachments are always expanded because their files are publicly served. + * This runs last so that callbacks written for REST output do not decide + * what a rendered block shows. + * + * @since SCF 6.9.7 + * + * @param boolean $exposable Whether the REST rules would expose the target. + * @param integer $target_id The referenced post or attachment ID. + * @return boolean + */ + public function expose_reference_target( $exposable, $target_id ) { + $post = get_post( $target_id ); + + if ( ! $post instanceof \WP_Post ) { + return false; + } + + if ( 'attachment' === $post->post_type ) { + return true; + } + + if ( post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ) ) { + return false; + } + + return is_post_publicly_viewable( $post ) || current_user_can( 'read_post', $post->ID ); + } } diff --git a/includes/acf-bidirectional-functions.php b/includes/acf-bidirectional-functions.php index 068407f7..163a71bd 100644 --- a/includes/acf-bidirectional-functions.php +++ b/includes/acf-bidirectional-functions.php @@ -34,12 +34,26 @@ function acf_update_bidirectional_values( $target_item_ids, $post_id, $field, $t return; } + $decoded_origin = acf_decode_post_id( $post_id ); + if ( 'post' === $decoded_origin['type'] && (int) $decoded_origin['id'] > 0 && wp_is_post_revision( (int) $decoded_origin['id'] ) ) { + return; + } + $update_plan = _scf_prepare_bidirectional_update( $target_item_ids, $post_id, $field, $target_prefix ); $valid_targets = $update_plan['target_fields']; $item_id = $update_plan['item_id']; $additions = $update_plan['additions']; $subtractions = $update_plan['subtractions']; + if ( acf_get_data( 'scf_saving_submitted_values' ) ) { + $additions = array_filter( + $additions, + static function ( $addition ) use ( $field ) { + return _scf_is_allowed_bidirectional_target( $addition, $field ); + } + ); + } + if ( ! empty( $valid_targets ) ) { acf_set_data( 'acf_doing_bidirectional_update', true ); @@ -60,6 +74,81 @@ function acf_update_bidirectional_values( $target_item_ids, $post_id, $field, $t } } +/** + * Check whether a submitted bidirectional target is a valid choice for the origin field. + * + * @since SCF 6.9.7 + * @internal + * + * @param integer|string $target ACF encoded target ID (e.g. 123, "user_1" or "term_5"). + * @param array $field The origin field. + * @return boolean + */ +function _scf_is_allowed_bidirectional_target( $target, $field ) { + $decoded = acf_decode_post_id( $target ); + $id = (int) $decoded['id']; + $allowed = true; + + switch ( $field['type'] ) { + case 'user': + $user = 'user' === $decoded['type'] && $id ? get_userdata( $id ) : false; + $roles = acf_get_array( $field['role'] ?? array() ); + if ( ! $user ) { + $allowed = false; + } elseif ( empty( $roles ) ) { + // The field only lists users of the current site. + $allowed = ! is_multisite() || is_user_member_of_blog( $user->ID ); + } else { + $allowed = (bool) array_intersect( $roles, (array) $user->roles ); + } + break; + + case 'relationship': + case 'post_object': + $post = 'post' === $decoded['type'] && $id ? get_post( $id ) : null; + if ( ! $post ) { + $allowed = false; + break; + } + $post_types = empty( $field['post_type'] ) ? acf_get_post_types() : acf_get_array( $field['post_type'] ); + $post_statuses = acf_get_array( $field['post_status'] ?? array() ); + $allowed = in_array( $post->post_type, $post_types, true ) + && ( empty( $post_statuses ) || in_array( 'any', $post_statuses, true ) || in_array( $post->post_status, $post_statuses, true ) ) + && ( is_post_publicly_viewable( $post ) || current_user_can( 'read_post', $post->ID ) ); + $terms = $allowed && ! empty( $field['taxonomy'] ) ? acf_decode_taxonomy_terms( acf_get_array( $field['taxonomy'] ) ) : array(); + if ( $terms ) { + // Match each field's choice query: Relationship ORs the taxonomies, Post Object ANDs them. + $matches = array(); + foreach ( $terms as $taxonomy => $slugs ) { + $matches[] = has_term( $slugs, $taxonomy, $post ); + } + $allowed = 'relationship' === $field['type'] ? in_array( true, $matches, true ) : ! in_array( false, $matches, true ); + } + break; + + case 'taxonomy': + $term = 'term' === $decoded['type'] && $id ? get_term( $id ) : null; + $allowed = $term instanceof WP_Term && $term->taxonomy === $field['taxonomy']; + break; + } + + // Users may always link to objects they can edit themselves, such as choices widened by a query filter. + if ( ! $allowed && in_array( $decoded['type'], array( 'post', 'user', 'term' ), true ) && $id ) { + $allowed = acf_current_user_can_edit_in_context( $decoded ); + } + + /** + * Filters whether a submitted value may receive a bidirectional back-reference. + * + * @since SCF 6.9.7 + * + * @param boolean $allowed Whether the target is a valid choice for the field. + * @param integer|string $target ACF encoded target ID. + * @param array $field The origin field. + */ + return (bool) apply_filters( 'scf/bidirectional/allow_target', (bool) $allowed, $target, $field ); +} + /** * Prepare a side-effect-free bidirectional update plan. * diff --git a/includes/acf-form-functions.php b/includes/acf-form-functions.php index 4afa9495..65ae0502 100644 --- a/includes/acf-form-functions.php +++ b/includes/acf-form-functions.php @@ -152,6 +152,9 @@ function scf_restrict_uploaded_field_keys( array $allowed_keys ): void { function acf_save_post( $post_id = 0, $values = null ) { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified elsewhere. + // Values passed by code while no user is logged in, such as WP-CLI or cron, were not submitted by anyone. + $programmatic = null !== $values && ! is_user_logged_in(); + // Override $_POST data with $values. if ( $values !== null ) { $_POST['acf'] = $values; @@ -172,7 +175,12 @@ function acf_save_post( $post_id = 0, $values = null ) { // phpcs:enable WordPress.Security.NonceVerification.Missing // Do generic action. - do_action( 'acf/save_post', $post_id ); + acf_set_form_data( 'scf_programmatic_save', $programmatic ); + try { + do_action( 'acf/save_post', $post_id ); + } finally { + acf_set_form_data( 'scf_programmatic_save', false ); + } // Return true. return true; @@ -194,7 +202,13 @@ function _acf_do_save_post( $post_id = 0 ) { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified elsewhere. if ( ! empty( $_POST['acf'] ) ) { - acf_update_values( $_POST['acf'], $post_id ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized by WP when saved. + $was_saving_submitted = acf_get_data( 'scf_saving_submitted_values' ); + acf_set_data( 'scf_saving_submitted_values', ! acf_get_form_data( 'scf_programmatic_save' ) ); + try { + acf_update_values( $_POST['acf'], $post_id ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Sanitized by WP when saved. + } finally { + acf_set_data( 'scf_saving_submitted_values', $was_saving_submitted ); + } } // phpcs:enable WordPress.Security.NonceVerification.Missing } diff --git a/includes/api/api-helpers.php b/includes/api/api-helpers.php index e1070109..7a380bff 100644 --- a/includes/api/api-helpers.php +++ b/includes/api/api-helpers.php @@ -2312,6 +2312,21 @@ function acf_get_valid_post_id( $post_id = 0 ) { return $post_id; } +/** + * Checks whether field values stored on an ACF post ID belong to a password-protected + * post whose password has not been entered yet. + * + * @since SCF 6.9.7 + * + * @param integer|string $post_id ACF encoded post ID. + * @return boolean + */ +function scf_post_password_required( $post_id ) { + $decoded = acf_decode_post_id( $post_id ); + + return 'post' === $decoded['type'] && (int) $decoded['id'] > 0 && post_password_required( (int) $decoded['id'] ); +} + /** @@ -2543,9 +2558,37 @@ function acf_upload_file( $uploaded_file ) { // Save the data $id = wp_insert_attachment( $object, $file ); + /** + * Filters whether a file uploaded through a basic File field gets preview images. + * + * @since SCF 6.9.7 + * + * @param boolean $generate_previews Whether to generate preview images. Default true only for users who can upload files. + * @param string $file Path to the uploaded file. + * @param integer $id Attachment ID. + */ + $skip_previews = ! apply_filters( 'scf/upload/generate_previews', current_user_can( 'upload_files' ), $file, $id ); + $no_cover_art = static function ( $metadata ) { + if ( is_array( $metadata ) ) { + unset( $metadata['image'] ); + } + return $metadata; + }; + if ( $skip_previews ) { + add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', PHP_INT_MAX ); + add_filter( 'wp_read_audio_metadata', $no_cover_art, PHP_INT_MAX ); + add_filter( 'wp_read_video_metadata', $no_cover_art, PHP_INT_MAX ); + } + // Add the meta-data wp_update_attachment_metadata( $id, wp_generate_attachment_metadata( $id, $file ) ); + if ( $skip_previews ) { + remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', PHP_INT_MAX ); + remove_filter( 'wp_read_audio_metadata', $no_cover_art, PHP_INT_MAX ); + remove_filter( 'wp_read_video_metadata', $no_cover_art, PHP_INT_MAX ); + } + /** This action is documented in wp-admin/custom-header.php */ do_action( 'wp_create_file_in_uploads', $file, $id ); // For replication diff --git a/includes/api/api-template.php b/includes/api/api-template.php index e92cd9f7..77be196a 100644 --- a/includes/api/api-template.php +++ b/includes/api/api-template.php @@ -1047,6 +1047,10 @@ function acf_shortcode( $atts ) { $post_id = acf_get_valid_post_id( $atts['post_id'] ); $decoded_post_id = acf_decode_post_id( $post_id ); + if ( scf_post_password_required( $post_id ) ) { + return; + } + // If we've decoded to a post, ensure the post is publicly visible. if ( $decoded_post_id['type'] === 'post' ) { if ( $atts['post_id'] !== false && ( (int) $atts['post_id'] !== (int) acf_get_valid_post_id() ) && ( ! is_post_publicly_viewable( $decoded_post_id['id'] ) ) && apply_filters( 'acf/shortcode/prevent_access_to_fields_on_non_public_posts', true ) ) { @@ -1067,8 +1071,13 @@ function acf_shortcode( $atts ) { } // Try to get the field value, ensuring any non-safe HTML is stripped from wysiwyg fields via `acf_the_content` - $field = get_field_object( $atts['field'], $post_id, $atts['format_value'], true, true ); - $value = $field ? $field['value'] : get_field( $atts['field'], $post_id, $atts['format_value'], true ); + $field = get_field_object( $atts['field'], $post_id, $atts['format_value'], true, true ); + $value = $field ? $field['value'] : get_field( $atts['field'], $post_id, $atts['format_value'], true ); + $raw_value = $field ? acf_get_value( $post_id, $field ) : null; + + if ( $field ) { + $value = scf_rest_sanitize_user_data( $value, $raw_value, $field ); + } $field_type = is_array( $field ) && isset( $field['type'] ) ? $field['type'] : 'text'; @@ -1102,6 +1111,10 @@ function acf_shortcode( $atts ) { // Temporarily always get the unescaped version for action comparison. $unescaped_value = get_field( $atts['field'], $post_id, $atts['format_value'], false ); + if ( $field ) { + $unescaped_value = scf_rest_sanitize_user_data( $unescaped_value, $raw_value, $field ); + } + // Remove the filter preventing access to unknown filters now we've got all the values. if ( $filter_applied ) { remove_filter( 'acf/prevent_access_to_unknown_fields', '__return_true' ); diff --git a/includes/rest-api/acf-rest-api-functions.php b/includes/rest-api/acf-rest-api-functions.php index c0775379..f422827e 100644 --- a/includes/rest-api/acf-rest-api-functions.php +++ b/includes/rest-api/acf-rest-api-functions.php @@ -513,16 +513,14 @@ function acf_rest_reference_is_exposable( $target_id, $field ) { $post_type_obj = get_post_type_object( $post->post_type ); if ( $post_type_obj && ! empty( $post_type_obj->show_in_rest ) ) { - $controller_class = ! empty( $post_type_obj->rest_controller_class ) - ? $post_type_obj->rest_controller_class - : 'WP_REST_Posts_Controller'; + $controller = $post_type_obj->get_rest_controller(); - if ( class_exists( $controller_class ) ) { - $controller = new $controller_class( $post->post_type ); + if ( $controller && method_exists( $controller, 'check_read_permission' ) ) { + $exposable = (bool) $controller->check_read_permission( $post ); + } - if ( method_exists( $controller, 'check_read_permission' ) ) { - $exposable = (bool) $controller->check_read_permission( $post ); - } + if ( $exposable && post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ) ) { + $exposable = false; } } } diff --git a/includes/rest-api/class-acf-rest-api.php b/includes/rest-api/class-acf-rest-api.php index 28bf6003..ccc44230 100644 --- a/includes/rest-api/class-acf-rest-api.php +++ b/includes/rest-api/class-acf-rest-api.php @@ -434,6 +434,28 @@ public function validate_rest_arg( $value, $request, $param ) { return true; } + /** + * Checks whether a post's field values must stay out of the response because the + * post is password protected and the caller has neither unlocked it nor can edit it. + * + * @since SCF 6.9.7 + * + * @param integer $post_id The post ID. + * @param WP_REST_Request $request The current request. + * @return boolean + */ + private function is_password_protected_content_hidden( $post_id, $request ) { + $post = get_post( $post_id ); + + if ( ! $post instanceof WP_Post || ! post_password_required( $post ) || current_user_can( 'edit_post', $post->ID ) ) { + return false; + } + + $password = $request instanceof WP_REST_Request ? $request->get_param( 'password' ) : ''; + + return ! is_string( $password ) || '' === $password || ! hash_equals( $post->post_password, $password ); + } + /** * Load field values into the requested object. This method is not a part of any public API and is only public as * it is required by WordPress. @@ -462,6 +484,10 @@ public function load_fields( $object, $field_name, $request, $object_sub_type ) $object_sub_type = str_replace( '-revision', '', $object_sub_type ); + if ( 'post' === $object_type && $this->is_password_protected_content_hidden( $object_id, $request ) ) { + return $fields; + } + // Get all field groups for the current object. $field_groups = $this->get_field_groups_by_id( $object_id, $object_type, $object_sub_type ); if ( empty( $field_groups ) ) { diff --git a/readme.txt b/readme.txt index cd5d2b10..27733e6b 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: fields, custom fields, meta, scf Requires at least: 6.2 Tested up to: 7.1 Requires PHP: 7.4 -Stable tag: 6.9.6 +Stable tag: 6.9.7 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -51,6 +51,17 @@ This plugin builds upon and is a fork of the previous work done by the contribut == Changelog == += 6.9.7 = +*Release Date 21st October 2026* + +*Security* + +- Submitted relationship values only link back to objects that are valid choices for the field, or that the submitting user can edit. Values saved by code while no user is logged in, such as WP-CLI or cron imports, are not affected. Revisions never link back. New filter: `scf/bidirectional/allow_target`. +- Preview images for files uploaded through front-end File fields, including PDF previews and audio or video cover art, are only generated for users who can upload to the media library. New filter: `scf/upload/generate_previews`. +- Password-protected posts referenced by a Post Object or Relationship field are only expanded for users who entered the password or can edit them. +- Field values shown through block bindings only expand referenced content and user details the viewer can read, and the shortcode outputs User fields as IDs. +- Field values of password-protected posts stay hidden in the shortcode, block bindings, JSON-LD and REST API output until the password is entered. + = 6.9.6 = *Release Date 16th October 2026* @@ -463,6 +474,9 @@ Revert from 6.5.2. == Upgrade Notice == += 6.9.7 = +Security: Front-end File field uploads only get preview images for users who can upload files, and submitted relationship values only link back to valid targets. + = 6.9.6 = Security: Image and Gallery fields now reject non-image files. On WordPress 7.1+, blocks without a version use block version 3. diff --git a/secure-custom-fields.php b/secure-custom-fields.php index a6a600a0..172b4b97 100644 --- a/secure-custom-fields.php +++ b/secure-custom-fields.php @@ -6,7 +6,7 @@ * Plugin Name: Secure Custom Fields * Plugin URI: https://developer.wordpress.org/secure-custom-fields/ * Description: Secure Custom Fields (SCF) offers an intuitive way for developers to enhance WordPress content management by adding extra fields and options without coding requirements. - * Version: 6.9.6 + * Version: 6.9.7 * Author: WordPress.org * Author URI: https://wordpress.org/ * Text Domain: secure-custom-fields @@ -33,7 +33,7 @@ class ACF { * * @var string */ - public $version = '6.9.6'; + public $version = '6.9.7'; /** * The plugin settings array. diff --git a/src/AI/GEO/Outputs/Blocks.php b/src/AI/GEO/Outputs/Blocks.php index def90ae1..5e807a80 100644 --- a/src/AI/GEO/Outputs/Blocks.php +++ b/src/AI/GEO/Outputs/Blocks.php @@ -151,6 +151,13 @@ public function output_block_jsonld_data( $block, $content, $is_preview, $post_i echo '\n"; } + if ( scf_post_password_required( $post_id ) ) { + if ( $debug ) { + echo "\n"; + } + return; + } + // Get the block type. $block_type = acf_get_block_type( $block['name'] ); if ( ! $block_type ) { diff --git a/src/AI/GEO/Outputs/Posts.php b/src/AI/GEO/Outputs/Posts.php index 0e132f6b..508680a0 100644 --- a/src/AI/GEO/Outputs/Posts.php +++ b/src/AI/GEO/Outputs/Posts.php @@ -107,6 +107,13 @@ public function output_jsonld_data() { return; } + if ( post_password_required( $post ) ) { + if ( $debug ) { + echo "\n"; + } + return; + } + /** * Filters the field objects before retrieval, allowing posts to provide custom data. * diff --git a/tests/php/includes/api/test-api-upload-file-content.php b/tests/php/includes/api/test-api-upload-file-content.php index ab619172..ef703bbe 100644 --- a/tests/php/includes/api/test-api-upload-file-content.php +++ b/tests/php/includes/api/test-api-upload-file-content.php @@ -59,6 +59,20 @@ class Test_API_Upload_File_Content extends BaseTestCase { */ private $attachment_action; + /** + * Number of times WordPress asked for an image editor. + * + * @var int + */ + private $image_editor_requests = 0; + + /** + * Image editor observer callback. + * + * @var callable + */ + private $image_editor_observer; + /** * Set up isolated request state and upload observers. * @@ -83,8 +97,19 @@ public function set_up(): void { $this->attachment_ids[] = $attachment_id; }; + $this->image_editor_requests = 0; + $this->image_editor_observer = function ( $editors ) { + ++$this->image_editor_requests; + return $editors; + }; + add_filter( 'wp_handle_upload_overrides', $this->upload_overrides ); add_action( 'add_attachment', $this->attachment_action ); + add_filter( 'wp_image_editors', $this->image_editor_observer ); + + // Register the sizes WordPress renders PDF previews at, so preview generation is attempted. + update_option( 'thumbnail_size_w', 150 ); + update_option( 'thumbnail_size_h', 150 ); } /** @@ -95,6 +120,12 @@ public function set_up(): void { public function tear_down(): void { remove_filter( 'wp_handle_upload_overrides', $this->upload_overrides ); remove_action( 'add_attachment', $this->attachment_action ); + remove_filter( 'wp_image_editors', $this->image_editor_observer ); + remove_all_filters( 'scf/upload/generate_previews' ); + delete_option( 'thumbnail_size_w' ); + delete_option( 'thumbnail_size_h' ); + remove_theme_support( 'post-thumbnails' ); + wp_set_current_user( 0 ); foreach ( $this->attachment_ids as $attachment_id ) { wp_delete_attachment( $attachment_id, true ); @@ -114,11 +145,6 @@ public function tear_down(): void { /** * A PostScript program that names the `%PDF-` marker after its own header is rejected. * - * Ghostscript searches for `%PDF-` in the bytes following any leading whitespace, but - * uses its PostScript interpreter when `%!PS` appears before that marker. Accepting the - * marker anywhere in the header would therefore store a file that Ghostscript runs as a - * PostScript program. - * * @return void */ public function test_postscript_naming_the_pdf_marker_is_rejected(): void { @@ -169,10 +195,7 @@ public function test_genuine_pdf_is_accepted(): void { } /** - * Leading whitespace before the marker is accepted, matching how Ghostscript reads it. - * - * Ghostscript consumes bytes up to and including a space before sniffing the header, so - * such a file is still read as a PDF and must not be treated as a disguised one. + * Leading whitespace before the marker is accepted. * * @return void */ @@ -214,6 +237,141 @@ public function test_non_pdf_upload_is_unaffected(): void { $this->assertCount( 1, $this->attachment_ids, 'A non-PDF upload should be unaffected.' ); } + /** + * A PDF from a visitor who cannot upload files is stored without loading an image editor. + * + * @return void + */ + public function test_pdf_from_visitor_skips_preview_generation(): void { + $this->prepare_upload_request( "%PDF-1.4\n1 0 obj<<>>endobj\n", 'visitor.pdf' ); + acf_upload_files(); + + $this->assertCount( 1, $this->attachment_ids, 'The PDF is still stored.' ); + $this->assertSame( 0, $this->image_editor_requests, 'No image editor should be loaded for this PDF.' ); + } + + /** + * A PDF from a user without the upload_files capability skips preview generation. + * + * @return void + */ + public function test_pdf_from_subscriber_skips_preview_generation(): void { + $this->set_current_user_role( 'subscriber' ); + + $this->prepare_upload_request( "%PDF-1.4\n1 0 obj<<>>endobj\n", 'subscriber.pdf' ); + acf_upload_files(); + + $this->assertCount( 1, $this->attachment_ids ); + $this->assertSame( 0, $this->image_editor_requests ); + } + + /** + * A PDF from a user who can upload to the media library still gets preview images. + * + * @return void + */ + public function test_pdf_from_user_who_can_upload_generates_preview(): void { + $this->set_current_user_role( 'author' ); + + $this->prepare_upload_request( "%PDF-1.4\n1 0 obj<<>>endobj\n", 'author.pdf' ); + acf_upload_files(); + + $this->assertCount( 1, $this->attachment_ids ); + $this->assertSame( 1, $this->image_editor_requests, 'Trusted uploaders keep PDF previews.' ); + } + + /** + * The filter can enable PDF previews for visitors. + * + * @return void + */ + public function test_pdf_preview_filter_can_enable_previews_for_visitors(): void { + add_filter( 'scf/upload/generate_previews', '__return_true' ); + + $this->prepare_upload_request( "%PDF-1.4\n1 0 obj<<>>endobj\n", 'visitor-preview.pdf' ); + acf_upload_files(); + + $this->assertSame( 1, $this->image_editor_requests ); + } + + /** + * Sites can turn PDF previews off for every basic upload. + * + * @return void + */ + public function test_pdf_preview_filter_can_disable_previews_for_trusted_users(): void { + $this->set_current_user_role( 'author' ); + add_filter( 'scf/upload/generate_previews', '__return_false' ); + + $this->prepare_upload_request( "%PDF-1.4\n1 0 obj<<>>endobj\n", 'no-preview.pdf' ); + acf_upload_files(); + + $this->assertSame( 0, $this->image_editor_requests ); + } + + /** + * Skipping a visitor's PDF preview does not affect later metadata generation. + * + * @return void + */ + public function test_skipped_pdf_preview_does_not_leak_into_later_requests(): void { + $this->prepare_upload_request( "%PDF-1.4\n1 0 obj<<>>endobj\n", 'visitor-first.pdf' ); + acf_upload_files(); + + $this->assertSame( + array( 'thumbnail', 'medium', 'large' ), + apply_filters( 'fallback_intermediate_image_sizes', array( 'thumbnail', 'medium', 'large' ), array() ), + 'The preview size override must be removed after the upload.' + ); + } + + /** + * Cover art in a visitor's audio file is not saved. + * + * @return void + */ + public function test_audio_cover_art_from_visitor_is_not_saved(): void { + add_theme_support( 'post-thumbnails' ); + $this->mock_upload_type = 'audio/flac'; + + $this->prepare_upload_request( $this->flac_with_cover_art( 'image/jpeg', 'cover' ), 'visitor.flac' ); + acf_upload_files(); + + $this->assertCount( 1, $this->attachment_ids, 'Only the audio file is stored.' ); + $this->assertSame( 0, $this->image_editor_requests ); + } + + /** + * Cover art in audio uploaded by a user who can upload files is still saved. + * + * @return void + */ + public function test_audio_cover_art_from_user_who_can_upload_is_saved(): void { + add_theme_support( 'post-thumbnails' ); + $this->set_current_user_role( 'author' ); + $this->mock_upload_type = 'audio/flac'; + + $this->prepare_upload_request( $this->flac_with_cover_art( 'image/jpeg', 'cover' ), 'author.flac' ); + acf_upload_files(); + + $this->assertCount( 2, $this->attachment_ids, 'The audio file and its cover art are stored.' ); + } + + /** + * Removing a visitor's cover art does not affect later metadata reads. + * + * @return void + */ + public function test_skipped_cover_art_does_not_leak_into_later_requests(): void { + $this->mock_upload_type = 'audio/flac'; + $this->prepare_upload_request( $this->flac_with_cover_art( 'image/jpeg', 'cover' ), 'visitor-first.flac' ); + acf_upload_files(); + + $metadata = array( 'image' => array( 'data' => 'cover' ) ); + $this->assertSame( $metadata, apply_filters( 'wp_read_audio_metadata', $metadata, '', '', array() ) ); + $this->assertSame( $metadata, apply_filters( 'wp_read_video_metadata', $metadata, '', '', array() ) ); + } + /** * Return a successful upload result when CLI upload validation fails. * @@ -236,6 +394,43 @@ public function mock_upload_error_as_success( &$file, $message ) { ); } + /** + * Builds a minimal FLAC file with an embedded picture block. + * + * @param string $mime The MIME type the picture declares. + * @param string $data The picture bytes. + * @return string + */ + private function flac_with_cover_art( string $mime, string $data ): string { + $stream_info = pack( 'n', 4096 ) . pack( 'n', 4096 ) . "\0\0\0\0\0\0"; + $stream_info .= pack( 'J', ( 44100 << 44 ) | ( 1 << 41 ) | ( 15 << 36 ) ) . str_repeat( "\0", 16 ); + $flac = 'fLaC' . chr( 0 ) . substr( pack( 'N', strlen( $stream_info ) ), 1 ) . $stream_info; + + $picture = pack( 'N', 3 ) . pack( 'N', strlen( $mime ) ) . $mime . pack( 'N', 0 ) . pack( 'N', 1 ) . pack( 'N', 1 ) . pack( 'N', 24 ) . pack( 'N', 0 ) . pack( 'N', strlen( $data ) ) . $data; + $flac .= chr( 0x80 | 6 ) . substr( pack( 'N', strlen( $picture ) ), 1 ) . $picture; + + // Audio frames, so the metadata reader can seek past the header blocks. + return $flac . str_repeat( "\xff\xf8\x69\x08\0\0", 1000 ); + } + + /** + * Log in as a new user with the given role. + * + * @param string $role Role name. + * @return void + */ + private function set_current_user_role( string $role ): void { + $user_id = wp_insert_user( + array( + 'user_login' => $role . '_' . uniqid(), + 'user_pass' => 'password', + 'role' => $role, + ) + ); + $this->assertIsInt( $user_id ); + wp_set_current_user( $user_id ); + } + /** * Populate a valid nonce and an upload fixture with the given bytes. * diff --git a/tests/php/includes/blocks/test-blocks-bindings.php b/tests/php/includes/blocks/test-blocks-bindings.php index 6cdb5405..4fe2ba07 100644 --- a/tests/php/includes/blocks/test-blocks-bindings.php +++ b/tests/php/includes/blocks/test-blocks-bindings.php @@ -90,6 +90,14 @@ public function set_up() { 'type' => 'text', 'allow_in_bindings' => false, ), + array( + 'key' => 'field_bindings_relationship', + 'name' => 'bindings_relationship', + 'label' => 'Relationship', + 'type' => 'relationship', + 'post_type' => array( 'post' ), + 'return_format' => 'object', + ), array( 'key' => 'field_bindings_group', 'name' => 'bindings_group', @@ -136,7 +144,7 @@ public function tear_down() { } acf_remove_local_field_group( 'group_blocks_bindings' ); - foreach ( array( 'field_bindings_text', 'field_bindings_select', 'field_bindings_private', 'field_bindings_group', 'field_bindings_group_inner' ) as $key ) { + foreach ( array( 'field_bindings_text', 'field_bindings_select', 'field_bindings_private', 'field_bindings_relationship', 'field_bindings_group', 'field_bindings_group_inner' ) as $key ) { acf_remove_local_field( $key ); } @@ -153,11 +161,11 @@ public function tear_down() { * format values with so their filters exist. */ private function ensure_field_type_filters() { - if ( has_filter( 'acf/format_value/type=select' ) ) { + if ( has_filter( 'acf/format_value/type=select' ) && has_filter( 'acf/format_value/type=relationship' ) ) { return; } - foreach ( array( 'select', 'group' ) as $type ) { + foreach ( array( 'select', 'group', 'relationship' ) as $type ) { $instance = acf_get_field_type( $type ); if ( $instance instanceof acf_field ) { acf_register_field_type( get_class( $instance ) ); @@ -165,6 +173,21 @@ private function ensure_field_type_filters() { } } + /** + * Expands the Relationship field to post objects. Its own query needs a database, + * which WorDBless does not provide. + */ + private function expand_relationship_without_query() { + add_filter( + 'acf/format_value/key=field_bindings_relationship', + static function ( $value, $post_id ) { + return array_map( 'get_post', (array) get_post_meta( $post_id, 'bindings_relationship', true ) ); + }, + 20, + 2 + ); + } + /** * Registers the acf/field binding source like acf/init would. * @@ -258,6 +281,176 @@ public function test_get_value_resolves_text_field() { ); } + /** + * A bound reference to content the viewer cannot read is reduced to IDs. + */ + public function test_get_value_reduces_references_the_viewer_cannot_read() { + $protected = wp_insert_post( + array( + 'post_type' => 'post', + 'post_title' => 'Protected target', + 'post_content' => 'Protected body', + 'post_status' => 'publish', + 'post_password' => 'bindings-secret', + ) + ); + $private = wp_insert_post( + array( + 'post_type' => 'post', + 'post_title' => 'Private target', + 'post_content' => 'Private body', + 'post_status' => 'private', + ) + ); + update_field( 'bindings_relationship', array( $protected, $private ), $this->post_id ); + acf_get_store( 'values' )->reset(); + $this->expand_relationship_without_query(); + wp_set_current_user( 0 ); + + $bindings = $this->register_source(); + $value = $bindings->get_value( array( 'key' => 'field_bindings_relationship' ), $this->get_block_instance(), 'content' ); + + $this->assertStringNotContainsString( 'bindings-secret', $value ); + $this->assertStringNotContainsString( 'Protected body', $value ); + $this->assertStringNotContainsString( 'Private body', $value ); + $this->assertSame( array( $protected, $private ), json_decode( $value, true ) ); + } + + /** + * A bound reference to a password-protected post is expanded once the password is entered. + */ + public function test_get_value_expands_unlocked_password_protected_references() { + $protected = wp_insert_post( + array( + 'post_type' => 'post', + 'post_title' => 'Protected target', + 'post_content' => 'Protected body', + 'post_status' => 'publish', + 'post_password' => 'bindings-secret', + ) + ); + update_field( 'bindings_relationship', array( $protected ), $this->post_id ); + acf_get_store( 'values' )->reset(); + $this->expand_relationship_without_query(); + wp_set_current_user( 0 ); + + $bindings = $this->register_source(); + $block = $this->get_block_instance(); + + add_filter( 'post_password_required', '__return_false' ); + $value = $bindings->get_value( array( 'key' => 'field_bindings_relationship' ), $block, 'content' ); + remove_filter( 'post_password_required', '__return_false' ); + + $this->assertStringContainsString( 'Protected body', $value ); + $this->assertStringNotContainsString( 'bindings-secret', $value ); + } + + /** + * Bindings expose referenced content by front-end readability, not REST visibility. + */ + public function test_expose_reference_target_uses_front_end_readability() { + wp_set_current_user( 0 ); + $bindings = $this->register_source(); + + $draft = wp_insert_post( + array( + 'post_type' => 'post', + 'post_title' => 'Draft parent', + 'post_status' => 'draft', + ) + ); + $attachment = wp_insert_post( + array( + 'post_type' => 'attachment', + 'post_title' => 'Image of a draft', + 'post_status' => 'inherit', + 'post_parent' => $draft, + 'post_mime_type' => 'image/png', + ) + ); + $private = wp_insert_post( + array( + 'post_type' => 'post', + 'post_title' => 'Private post', + 'post_status' => 'private', + ) + ); + + $this->assertTrue( $bindings->expose_reference_target( false, $attachment ), 'Attachments are public files' ); + $this->assertTrue( $bindings->expose_reference_target( false, $this->post_id ), 'Published posts' ); + $this->assertFalse( $bindings->expose_reference_target( true, $draft ), 'Drafts' ); + $this->assertFalse( $bindings->expose_reference_target( true, $private ), 'Private posts' ); + $this->assertFalse( $bindings->expose_reference_target( true, 999999 ), 'Missing posts' ); + + $editor = wp_insert_user( + array( + 'user_login' => 'bindings-editor', + 'user_pass' => 'password', + 'role' => 'editor', + ) + ); + wp_set_current_user( $editor ); + $this->assertTrue( $bindings->expose_reference_target( false, $private ), 'Editors read private posts' ); + wp_set_current_user( 0 ); + } + + /** + * A bound reference to published content is still expanded. + */ + public function test_get_value_keeps_readable_references() { + $public = wp_insert_post( + array( + 'post_type' => 'post', + 'post_title' => 'Public target', + 'post_content' => 'Public body', + 'post_status' => 'publish', + ) + ); + update_field( 'bindings_relationship', array( $public ), $this->post_id ); + acf_get_store( 'values' )->reset(); + $this->expand_relationship_without_query(); + wp_set_current_user( 0 ); + + $bindings = $this->register_source(); + $value = $bindings->get_value( array( 'key' => 'field_bindings_relationship' ), $this->get_block_instance(), 'content' ); + + $this->assertStringContainsString( 'Public target', $value ); + + // Callbacks written for REST output do not decide what a rendered block shows. + add_filter( 'acf/rest/expose_reference_target', '__return_false' ); + acf_get_store( 'values' )->reset(); + $value = $bindings->get_value( array( 'key' => 'field_bindings_relationship' ), $this->get_block_instance(), 'content' ); + remove_filter( 'acf/rest/expose_reference_target', '__return_false' ); + + $this->assertStringContainsString( 'Public target', $value ); + } + + /** + * Field values of a password-protected post stay hidden until the password is entered. + */ + public function test_get_value_hides_values_of_password_protected_posts() { + update_field( 'bindings_text', 'Locked value', $this->post_id ); + wp_update_post( + array( + 'ID' => $this->post_id, + 'post_password' => 'bindings-secret', + ) + ); + $GLOBALS['post'] = get_post( $this->post_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + acf_get_store( 'values' )->reset(); + + $bindings = $this->register_source(); + $block = $this->get_block_instance(); + + $this->assertSame( '', $bindings->get_value( array( 'key' => 'field_bindings_text' ), $block, 'content' ) ); + + add_filter( 'post_password_required', '__return_false' ); + $unlocked = $bindings->get_value( array( 'key' => 'field_bindings_text' ), $block, 'content' ); + remove_filter( 'post_password_required', '__return_false' ); + + $this->assertSame( 'Locked value', $unlocked ); + } + /** * Test missing or invalid source attributes. */ diff --git a/tests/php/includes/functions/test-acf-bidirectional-functions.php b/tests/php/includes/functions/test-acf-bidirectional-functions.php index a18a55bb..ddbb13f6 100644 --- a/tests/php/includes/functions/test-acf-bidirectional-functions.php +++ b/tests/php/includes/functions/test-acf-bidirectional-functions.php @@ -884,4 +884,432 @@ public function test_non_core_field_type_uses_explicit_target_prefix() { acf_update_bidirectional_values( array( $user_id ), $source, $field, 'user' ); $this->assertSame( array( (string) $source ), get_field( 'field_noncore_subtraction_target', "user_{$user_id}", false ) ); } + + /** + * Register a core relational field type for submitted-value tests. + * + * @param string $type Field type name. + */ + private function register_relational_field_type( $type ) { + $class = "acf_field_{$type}"; + if ( ! class_exists( $class ) ) { + acf_include( "includes/fields/class-acf-field-{$type}.php" ); + } elseif ( ! has_filter( "acf/update_value/type={$type}" ) ) { + acf_register_field_type( $class ); + } + } + + /** + * Save values the way a submitted form does. + * + * @param array $values Submitted values keyed by field key. + * @param integer|string $post_id Origin object ID. + */ + private function save_submitted_values( $values, $post_id ) { + $original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Test fixture. + $_POST['acf'] = $values; + acf_save_post( $post_id ); + $_POST = $original_post; + } + + /** + * Registers a page-limited Relationship field with a bidirectional target. + */ + private function add_page_relationship_fields() { + $this->register_relational_field_type( 'relationship' ); + acf_add_local_field( + array( + 'key' => 'field_bidi_post_source', + 'name' => 'bidi_post_source', + 'type' => 'relationship', + 'post_type' => array( 'page' ), + 'taxonomy' => array(), + 'bidirectional' => 1, + 'bidirectional_target' => array( 'field_bidi_post_target' ), + ) + ); + acf_add_local_field( + array( + 'key' => 'field_bidi_post_target', + 'name' => 'bidi_post_target', + 'type' => 'relationship', + 'post_type' => array(), + 'taxonomy' => array(), + ) + ); + } + + /** + * Values passed to acf_save_post() while no user is logged in are not gated as submissions. + */ + public function test_programmatic_save_without_user_links_back_to_any_target() { + acf_update_setting( 'enable_bidirection', true ); + $this->add_page_relationship_fields(); + wp_set_current_user( 0 ); + + $source_id = wp_insert_post( + array( + 'post_title' => 'Source', + 'post_status' => 'publish', + ) + ); + $private_id = wp_insert_post( + array( + 'post_title' => 'Private target', + 'post_type' => 'page', + 'post_status' => 'private', + ) + ); + + $original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Test fixture. + acf_save_post( $source_id, array( 'field_bidi_post_source' => array( (string) $private_id ) ) ); + $_POST = $original_post; + + $this->assertSame( array( (string) $source_id ), get_field( 'field_bidi_post_target', $private_id, false ) ); + $this->assertFalse( acf_get_form_data( 'scf_programmatic_save' ) ); + } + + /** + * Values passed to acf_save_post() on behalf of a logged-in user are still gated. + */ + public function test_programmatic_save_by_logged_in_user_is_gated() { + acf_update_setting( 'enable_bidirection', true ); + $this->add_page_relationship_fields(); + + wp_set_current_user( 0 ); + + $source_id = wp_insert_post( + array( + 'post_title' => 'Source', + 'post_status' => 'publish', + ) + ); + $private_id = wp_insert_post( + array( + 'post_title' => 'Private target', + 'post_type' => 'page', + 'post_status' => 'private', + ) + ); + $public_id = wp_insert_post( + array( + 'post_title' => 'Public target', + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'bidi-save-subscriber', + 'user_pass' => 'password', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Test fixture. + acf_save_post( $source_id, array( 'field_bidi_post_source' => array( (string) $private_id, (string) $public_id ) ) ); + $_POST = $original_post; + wp_set_current_user( 0 ); + + $this->assertEmpty( get_post_meta( $private_id, 'bidi_post_target', true ), 'Private posts are not linked' ); + $this->assertSame( array( (string) $source_id ), get_field( 'field_bidi_post_target', $public_id, false ) ); + } + + /** + * Submitted User field values outside the field's roles must not receive back-references. + */ + public function test_submitted_user_outside_allowed_roles_is_not_linked() { + acf_update_setting( 'enable_bidirection', true ); + $this->register_relational_field_type( 'user' ); + $this->register_relational_field_type( 'relationship' ); + wp_set_current_user( 0 ); + + $editor_id = wp_insert_user( + array( + 'user_login' => 'bidi-editor', + 'user_pass' => 'password', + 'role' => 'editor', + ) + ); + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'bidi-subscriber', + 'user_pass' => 'password', + 'role' => 'subscriber', + ) + ); + $source_id = wp_insert_post( + array( + 'post_title' => 'Source', + 'post_status' => 'publish', + ) + ); + acf_add_local_field( + array( + 'key' => 'field_bidi_user_source', + 'name' => 'bidi_user_source', + 'type' => 'user', + 'role' => array( 'subscriber' ), + 'multiple' => 0, + 'bidirectional' => 1, + 'bidirectional_target' => array( 'field_bidi_user_target' ), + ) + ); + acf_add_local_field( + array( + 'key' => 'field_bidi_user_target', + 'name' => 'bidi_user_target', + 'type' => 'relationship', + 'post_type' => array(), + 'taxonomy' => array(), + ) + ); + + $this->save_submitted_values( array( 'field_bidi_user_source' => (string) $editor_id ), $source_id ); + $this->assertEmpty( get_user_meta( $editor_id, 'bidi_user_target', true ), 'Users outside the field roles are not linked' ); + $this->assertEmpty( acf_get_data( 'scf_saving_submitted_values' ) ); + + $this->save_submitted_values( array( 'field_bidi_user_source' => (string) $subscriber_id ), $source_id ); + $this->assertSame( array( (string) $source_id ), get_field( 'field_bidi_user_target', "user_{$subscriber_id}", false ) ); + + // Programmatic updates are not filtered. + update_field( 'field_bidi_user_source', $editor_id, $source_id ); + $this->assertSame( array( (string) $source_id ), get_field( 'field_bidi_user_target', "user_{$editor_id}", false ) ); + } + + /** + * Submitted Relationship values the current user cannot read must not receive back-references. + */ + public function test_submitted_unreadable_post_is_not_linked() { + acf_update_setting( 'enable_bidirection', true ); + $this->add_page_relationship_fields(); + wp_set_current_user( 0 ); + + $source_id = wp_insert_post( + array( + 'post_title' => 'Source', + 'post_status' => 'publish', + ) + ); + $private_id = wp_insert_post( + array( + 'post_title' => 'Private target', + 'post_type' => 'page', + 'post_status' => 'private', + ) + ); + $public_id = wp_insert_post( + array( + 'post_title' => 'Public target', + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + $other_id = wp_insert_post( + array( + 'post_title' => 'Wrong post type', + 'post_status' => 'publish', + ) + ); + + $this->save_submitted_values( array( 'field_bidi_post_source' => array( (string) $private_id, (string) $public_id, (string) $other_id ) ), $source_id ); + + $this->assertEmpty( get_post_meta( $private_id, 'bidi_post_target', true ), 'Private posts are not linked' ); + $this->assertEmpty( get_post_meta( $other_id, 'bidi_post_target', true ), 'Other post types are not linked' ); + $this->assertSame( array( (string) $source_id ), get_field( 'field_bidi_post_target', $public_id, false ) ); + } + + /** + * Registers a role-limited User field with a bidirectional Relationship target on users. + */ + private function add_role_limited_user_fields() { + $this->register_relational_field_type( 'user' ); + $this->register_relational_field_type( 'relationship' ); + acf_add_local_field( + array( + 'key' => 'field_bidi_user_source', + 'name' => 'bidi_user_source', + 'type' => 'user', + 'role' => array( 'subscriber' ), + 'multiple' => 0, + 'bidirectional' => 1, + 'bidirectional_target' => array( 'field_bidi_user_target' ), + ) + ); + acf_add_local_field( + array( + 'key' => 'field_bidi_user_target', + 'name' => 'bidi_user_target', + 'type' => 'relationship', + 'post_type' => array(), + 'taxonomy' => array(), + ) + ); + } + + /** + * A submitter who can edit the target may link to it even when it is not a listed choice. + */ + public function test_submitted_target_the_user_can_edit_is_linked() { + acf_update_setting( 'enable_bidirection', true ); + $this->add_role_limited_user_fields(); + + $admin_id = wp_insert_user( + array( + 'user_login' => 'bidi-editor-admin', + 'user_pass' => 'password', + 'role' => 'administrator', + ) + ); + $editor_id = wp_insert_user( + array( + 'user_login' => 'bidi-editor-target', + 'user_pass' => 'password', + 'role' => 'editor', + ) + ); + $source_id = wp_insert_post( + array( + 'post_title' => 'Admin submission', + 'post_status' => 'publish', + ) + ); + wp_set_current_user( $admin_id ); + + $this->save_submitted_values( array( 'field_bidi_user_source' => (string) $editor_id ), $source_id ); + $this->assertSame( array( (string) $source_id ), get_field( 'field_bidi_user_target', "user_{$editor_id}", false ) ); + + wp_set_current_user( 0 ); + } + + /** + * Saving values to a revision never writes back-references. + */ + public function test_revision_origin_never_links_back() { + acf_update_setting( 'enable_bidirection', true ); + $this->add_role_limited_user_fields(); + wp_set_current_user( 0 ); + + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'bidi-revision-subscriber', + 'user_pass' => 'password', + 'role' => 'subscriber', + ) + ); + $parent_id = wp_insert_post( + array( + 'post_title' => 'Parent', + 'post_status' => 'publish', + ) + ); + $revision_id = wp_insert_post( + array( + 'post_title' => 'Parent', + 'post_type' => 'revision', + 'post_status' => 'inherit', + 'post_parent' => $parent_id, + ) + ); + $this->assertSame( $parent_id, wp_is_post_revision( $revision_id ) ); + + update_field( 'field_bidi_user_source', $subscriber_id, $revision_id ); + $this->assertEmpty( get_user_meta( $subscriber_id, 'bidi_user_target', true ) ); + } + + /** + * The submitted-values flag is restored even when saving throws. + */ + public function test_submitted_flag_is_restored_after_an_exception() { + acf_update_setting( 'enable_bidirection', true ); + $this->add_role_limited_user_fields(); + $source_id = wp_insert_post( array( 'post_title' => 'Source' ) ); + $thrower = static function ( $value ) { + if ( null !== $value ) { + throw new RuntimeException( 'save failed' ); + } + return $value; + }; + add_filter( 'acf/update_value', $thrower ); + + try { + $this->save_submitted_values( array( 'field_bidi_user_source' => '1' ), $source_id ); + $this->fail( 'The save should have thrown.' ); + } catch ( RuntimeException $e ) { + $this->assertSame( 'save failed', $e->getMessage() ); + } finally { + remove_filter( 'acf/update_value', $thrower ); + } + + $this->assertEmpty( acf_get_data( 'scf_saving_submitted_values' ) ); + } + + /** + * Submitted post targets follow each field's taxonomy and status semantics. + */ + public function test_allowed_post_target_matches_field_choice_query() { + wp_set_current_user( 0 ); + $both_id = wp_insert_post( + array( + 'post_title' => 'In both taxonomies', + 'post_status' => 'publish', + ) + ); + $category_id = wp_insert_post( + array( + 'post_title' => 'Category only', + 'post_status' => 'publish', + ) + ); + // Prime the caches has_term() reads; the test datastore keeps no terms or term relationships. + $news = 9001; + $featured = 9002; + $fixtures = array( + $news => array( 'news', 'category' ), + $featured => array( 'featured', 'post_tag' ), + ); + foreach ( $fixtures as $term_id => list( $slug, $taxonomy ) ) { + $term = (object) array( + 'term_id' => $term_id, + 'name' => $slug, + 'slug' => $slug, + 'term_group' => 0, + 'term_taxonomy_id' => $term_id, + 'taxonomy' => $taxonomy, + 'description' => '', + 'parent' => 0, + 'count' => 1, + 'filter' => 'raw', + ); + wp_cache_set( $term_id, $term, 'terms' ); + } + wp_cache_set( $both_id, array( $news ), 'category_relationships' ); + wp_cache_set( $both_id, array( $featured ), 'post_tag_relationships' ); + wp_cache_set( $category_id, array( $news ), 'category_relationships' ); + wp_cache_set( $category_id, array(), 'post_tag_relationships' ); + + $field = array( + 'type' => 'relationship', + 'post_type' => array( 'post' ), + 'post_status' => array(), + 'taxonomy' => array( 'category:news', 'post_tag:featured' ), + ); + $this->assertTrue( _scf_is_allowed_bidirectional_target( $both_id, $field ) ); + $this->assertTrue( _scf_is_allowed_bidirectional_target( $category_id, $field ), 'Relationship matches any configured taxonomy' ); + + $field['type'] = 'post_object'; + $this->assertTrue( _scf_is_allowed_bidirectional_target( $both_id, $field ) ); + $this->assertFalse( _scf_is_allowed_bidirectional_target( $category_id, $field ), 'Post Object must match every configured taxonomy' ); + + $field['taxonomy'] = array(); + $field['post_status'] = array( 'draft' ); + $this->assertFalse( _scf_is_allowed_bidirectional_target( $both_id, $field ), 'Status outside the field configuration' ); + $field['post_status'] = array( 'any' ); + $this->assertTrue( _scf_is_allowed_bidirectional_target( $both_id, $field ) ); + + $this->assertFalse( _scf_is_allowed_bidirectional_target( 0, $field ), 'Malformed IDs resolve to no object' ); + $this->assertFalse( _scf_is_allowed_bidirectional_target( "user_{$both_id}", $field ), 'Wrong object type' ); + } } diff --git a/tests/php/includes/rest-api/test-acf-rest-api.php b/tests/php/includes/rest-api/test-acf-rest-api.php index 7c677379..05d4393a 100644 --- a/tests/php/includes/rest-api/test-acf-rest-api.php +++ b/tests/php/includes/rest-api/test-acf-rest-api.php @@ -414,6 +414,115 @@ public function test_load_fields_returns_empty_without_field_groups() { $this->assertIsArray( $result ); } + /** + * Builds a stand-in for the parsed ACF REST request of a post. + * + * @return object + */ + private function get_mock_post_request() { + return new class() { + /** + * The REST object type. + * + * @var string + */ + public $object_type = 'post'; + + /** + * The REST object subtype. + * + * @var string + */ + public $object_sub_type = 'post'; + + /** + * The REST request method. + * + * @var string + */ + public $http_method = 'GET'; + + /** + * Get a URL parameter value. + * + * @param string $param The URL parameter name. + * @return null + */ + public function get_url_param( $param ) { + return null; + } + }; + } + + /** + * Field values of a password-protected post are only returned once the post is unlocked. + */ + public function test_load_fields_hides_password_protected_post_values() { + wp_set_current_user( 0 ); + + $protected_id = wp_insert_post( + array( + 'post_title' => 'Protected Post', + 'post_status' => 'publish', + 'post_type' => 'post', + 'post_password' => 'rest-secret', + ) + ); + + acf_add_local_field_group( + array_merge( + $this->test_field_group, + array( + 'key' => 'group_test_rest_protected', + 'title' => 'Test REST Protected', + 'fields' => array( + array( + 'key' => 'field_test_rest_protected_text', + 'label' => 'Protected Text', + 'name' => 'test_rest_protected_text', + 'type' => 'text', + ), + ), + ) + ) + ); + update_field( 'field_test_rest_protected_text', 'Locked value', $protected_id ); + acf_get_store( 'fields' )->reset(); + acf_get_store( 'values' )->reset(); + + $request_prop = $this->reflection->getProperty( 'request' ); + $request_prop->setAccessible( true ); + $request_prop->setValue( $this->rest_api, $this->get_mock_post_request() ); + + $object = array( 'id' => $protected_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . $protected_id ); + + $this->assertSame( array(), $this->rest_api->load_fields( $object, 'acf', $request, 'post' ), 'Anonymous readers' ); + + $wrong_password = new WP_REST_Request( 'GET', '/wp/v2/posts/' . $protected_id ); + $wrong_password->set_param( 'password', 'not-the-password' ); + $this->assertSame( array(), $this->rest_api->load_fields( $object, 'acf', $wrong_password, 'post' ), 'Wrong password' ); + + $right_password = new WP_REST_Request( 'GET', '/wp/v2/posts/' . $protected_id ); + $right_password->set_param( 'password', 'rest-secret' ); + $this->assertSame( 'Locked value', $this->rest_api->load_fields( $object, 'acf', $right_password, 'post' )['test_rest_protected_text'], 'Right password' ); + + $editor = wp_insert_user( + array( + 'user_login' => 'rest-protected-editor', + 'user_pass' => 'password', + 'role' => 'editor', + ) + ); + wp_set_current_user( $editor ); + $this->assertSame( 'Locked value', $this->rest_api->load_fields( $object, 'acf', $request, 'post' )['test_rest_protected_text'], 'Editors' ); + wp_set_current_user( 0 ); + + acf_remove_local_field_group( 'group_test_rest_protected' ); + acf_remove_local_field( 'field_test_rest_protected_text' ); + wp_delete_post( $protected_id, true ); + } + /** * Test load_fields uses REST-safe formatted values for User fields. */ diff --git a/tests/php/includes/rest-api/test-acf-rest-reference-sanitization.php b/tests/php/includes/rest-api/test-acf-rest-reference-sanitization.php index bdac1a29..26b41ea7 100644 --- a/tests/php/includes/rest-api/test-acf-rest-reference-sanitization.php +++ b/tests/php/includes/rest-api/test-acf-rest-reference-sanitization.php @@ -103,6 +103,46 @@ public function test_is_exposable_for_editor() { $this->assertTrue( acf_rest_reference_is_exposable( $this->private_id, $field ) ); } + /** + * Password-protected posts are only exposable to users who can edit them. + */ + public function test_is_exposable_hides_password_protected_posts() { + $field = $this->field( 'post_object' ); + $protected_id = $this->create_protected_post(); + + $this->assertFalse( acf_rest_reference_is_exposable( $protected_id, $field ), 'Anonymous readers' ); + + $this->set_role( 'author' ); + $this->assertFalse( acf_rest_reference_is_exposable( $protected_id, $field ), 'Authors who cannot edit the post' ); + + $this->set_role( 'editor' ); + $this->assertTrue( acf_rest_reference_is_exposable( $protected_id, $field ), 'Editors' ); + + wp_set_current_user( 0 ); + $this->add_test_filter( 'post_password_required', '__return_false' ); + $this->assertTrue( acf_rest_reference_is_exposable( $protected_id, $field ), 'Visitors who entered the password' ); + + wp_delete_post( $protected_id, true ); + } + + /** + * Password-protected references are reduced to IDs. + */ + public function test_password_protected_reference_is_reduced() { + $protected_id = $this->create_protected_post(); + $protected = get_post( $protected_id ); + + $this->assertSame( $protected_id, acf_rest_sanitize_reference_data( $protected, $protected_id, $this->field( 'post_object' ) ) ); + + $mixed = array( get_post( $this->public_id ), $protected ); + $this->assertSame( + array( $this->public_id, $protected_id ), + acf_rest_sanitize_reference_data( $mixed, array( $this->public_id, $protected_id ), $this->field( 'relationship' ) ) + ); + + wp_delete_post( $protected_id, true ); + } + /** * The filter can expose a target the default check would reduce. */ @@ -387,6 +427,23 @@ private function create_post( $status ) { ); } + /** + * Creates a published, password-protected post. + * + * @return int + */ + private function create_protected_post() { + return wp_insert_post( + array( + 'post_title' => 'Reference protected', + 'post_status' => 'publish', + 'post_type' => 'post', + 'post_content' => 'Protected body', + 'post_password' => 'secret', + ) + ); + } + /** * Logs in a new user with the given role. * diff --git a/tests/php/includes/test-protected-content-output.php b/tests/php/includes/test-protected-content-output.php new file mode 100644 index 00000000..f1d80193 --- /dev/null +++ b/tests/php/includes/test-protected-content-output.php @@ -0,0 +1,234 @@ +public_id = wp_insert_post( + array( + 'post_title' => 'Public post', + 'post_status' => 'publish', + ) + ); + $this->protected_id = wp_insert_post( + array( + 'post_title' => 'Protected post', + 'post_status' => 'publish', + 'post_password' => 'output-secret', + ) + ); + + acf_add_local_field_group( + array( + 'key' => 'group_protected_output', + 'title' => 'Protected Output', + 'fields' => array( + array( + 'key' => 'field_protected_output_text', + 'name' => 'protected_output_text', + 'label' => 'Text', + 'type' => 'text', + ), + array( + 'key' => 'field_protected_output_user', + 'name' => 'protected_output_user', + 'label' => 'User', + 'type' => 'user', + 'return_format' => 'array', + 'multiple' => 0, + 'role' => array(), + ), + ), + 'location' => array( + array( + array( + 'param' => 'post_type', + 'operator' => '==', + 'value' => 'post', + ), + ), + ), + ) + ); + update_field( 'protected_output_text', 'Locked value', $this->protected_id ); + acf_get_store( 'values' )->reset(); + + $this->original_shortcode_setting = acf_get_setting( 'enable_shortcode' ); + acf_update_setting( 'enable_shortcode', true ); + + wp_set_current_user( 0 ); + } + + /** + * Remove test data. + */ + public function tear_down() { + acf_update_setting( 'enable_shortcode', $this->original_shortcode_setting ); + acf_remove_local_field_group( 'group_protected_output' ); + acf_remove_local_field( 'field_protected_output_text' ); + acf_remove_local_field( 'field_protected_output_user' ); + acf_get_store( 'values' )->reset(); + unset( $GLOBALS['post'] ); + $GLOBALS['wp_query'] = $GLOBALS['wp_the_query']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + parent::tear_down(); + } + + /** + * The shortcode does not show fields of a password-protected post until the password is entered. + */ + public function test_shortcode_hides_password_protected_post_fields() { + $GLOBALS['post'] = get_post( $this->public_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $atts = array( + 'field' => 'protected_output_text', + 'post_id' => $this->protected_id, + ); + + $this->assertEmpty( acf_shortcode( $atts ) ); + + add_filter( 'post_password_required', '__return_false' ); + $unlocked = acf_shortcode( $atts ); + remove_filter( 'post_password_required', '__return_false' ); + + $this->assertSame( 'Locked value', $unlocked ); + } + + /** + * The shortcode outputs User fields as IDs rather than account details. + */ + public function test_shortcode_reduces_user_fields_to_ids() { + $user_login = uniqid( 'shortcode_user_', false ); + $user_email = "{$user_login}@example.invalid"; + $user_id = wp_insert_user( + array( + 'user_login' => $user_login, + 'user_pass' => 'password', + 'user_email' => $user_email, + ) + ); + update_field( 'protected_output_user', $user_id, $this->public_id ); + acf_get_store( 'values' )->reset(); + + // The test datastore has no users table for WP_User_Query to read. + $shim_user_query = static function ( $results, $query ) { + $user_ids = array_map( 'intval', (array) ( $query->query_vars['include'] ?? array() ) ); + $query->total_users = count( $user_ids ); + return $user_ids; + }; + add_filter( 'users_pre_query', $shim_user_query, 10, 2 ); + + $GLOBALS['post'] = get_post( $this->public_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $output = acf_shortcode( array( 'field' => 'protected_output_user' ) ); + + remove_filter( 'users_pre_query', $shim_user_query, 10 ); + + $this->assertStringNotContainsString( $user_email, (string) $output ); + $this->assertStringNotContainsString( $user_login, (string) $output ); + $this->assertSame( (string) $user_id, (string) $output ); + } + + /** + * The shortcode does not show fields of the current post while it is locked. + */ + public function test_shortcode_hides_current_password_protected_post_fields() { + $GLOBALS['post'] = get_post( $this->protected_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + $this->assertEmpty( acf_shortcode( array( 'field' => 'protected_output_text' ) ) ); + } + + /** + * JSON-LD for a password-protected post does not include its field values. + */ + public function test_post_jsonld_hides_password_protected_post_fields() { + $this->assertStringNotContainsString( 'Locked value', $this->render_post_jsonld( $this->protected_id ) ); + + add_filter( 'post_password_required', '__return_false' ); + $unlocked = $this->render_post_jsonld( $this->protected_id ); + remove_filter( 'post_password_required', '__return_false' ); + + $this->assertStringContainsString( 'Locked value', $unlocked ); + } + + /** + * Renders the JSON-LD a singular post view would print. + * + * @param int $post_id The post ID. + * @return string + */ + private function render_post_jsonld( $post_id ) { + $GLOBALS['post'] = get_post( $post_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $GLOBALS['wp_query'] = new WP_Query(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $GLOBALS['wp_query']->is_singular = true; + $GLOBALS['wp_query']->is_single = true; + + $enable = static function () { + return array( 'post' ); + }; + $fields = static function () { + return array( + 'protected_output_text' => array( + 'type' => 'text', + 'name' => 'protected_output_text', + 'value' => get_field( 'protected_output_text', false, false ), + 'schema_property' => 'description', + ), + ); + }; + add_filter( 'acf/schema/enabled_post_types', $enable ); + add_filter( 'acf/schema/post_field_objects', $fields ); + + ob_start(); + ( new Posts() )->output_jsonld_data(); + $output = ob_get_clean(); + + remove_filter( 'acf/schema/enabled_post_types', $enable ); + remove_filter( 'acf/schema/post_field_objects', $fields ); + + return $output; + } +}