From 751032edc1610898d85e0e7d09a556af5e4a056e Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Wed, 21 Oct 2026 12:40:28 +0200 Subject: [PATCH 1/9] Only generate upload previews for users who can upload files Front-end File fields no longer create PDF previews or audio and video cover art for users who cannot upload to the media library. New filter: scf/upload/generate_previews. Co-Authored-By: Claude Opus 5.5 --- includes/api/api-helpers.php | 32 +++ readme.txt | 7 + .../api/test-api-upload-file-content.php | 203 ++++++++++++++++++ 3 files changed, 242 insertions(+) diff --git a/includes/api/api-helpers.php b/includes/api/api-helpers.php index e1070109..9dc062ed 100644 --- a/includes/api/api-helpers.php +++ b/includes/api/api-helpers.php @@ -2543,9 +2543,41 @@ function acf_upload_file( $uploaded_file ) { // Save the data $id = wp_insert_attachment( $object, $file ); + // Only generate preview images, including audio and video cover art, for users who can upload to the media library. + /** + * 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_preview_sizes = static function () { + return array(); + }; + $no_cover_art = static function ( $metadata ) { + if ( is_array( $metadata ) ) { + unset( $metadata['image'] ); + } + return $metadata; + }; + if ( $skip_previews ) { + add_filter( 'fallback_intermediate_image_sizes', $no_preview_sizes, 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', $no_preview_sizes, 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/readme.txt b/readme.txt index cd5d2b10..67dcc85f 100644 --- a/readme.txt +++ b/readme.txt @@ -51,6 +51,13 @@ This plugin builds upon and is a fork of the previous work done by the contribut == Changelog == += 6.9.7 = +*Release Date 20th October 2026* + +*Security* + +- 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`. + = 6.9.6 = *Release Date 16th October 2026* 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..1820ec63 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 ); @@ -214,6 +245,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 embedded in a visitor's audio file is not saved, so it never reaches an image editor. + * + * @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( 'application/pdf', "%PDF-1.4\n" ), '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 +402,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. * From 4685ebde2d18c5c90e3c44d2d2e02990efb82f61 Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Wed, 21 Oct 2026 12:40:30 +0200 Subject: [PATCH 2/9] Tidy upload preview checks and tests Follow-up cleanup to the upload preview fix. Co-Authored-By: Claude Opus 5.5 --- includes/api/api-helpers.php | 1 - .../includes/api/test-api-upload-file-content.php | 14 +++----------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/includes/api/api-helpers.php b/includes/api/api-helpers.php index 9dc062ed..2940b9f9 100644 --- a/includes/api/api-helpers.php +++ b/includes/api/api-helpers.php @@ -2543,7 +2543,6 @@ function acf_upload_file( $uploaded_file ) { // Save the data $id = wp_insert_attachment( $object, $file ); - // Only generate preview images, including audio and video cover art, for users who can upload to the media library. /** * Filters whether a file uploaded through a basic File field gets preview images. * 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 1820ec63..ef703bbe 100644 --- a/tests/php/includes/api/test-api-upload-file-content.php +++ b/tests/php/includes/api/test-api-upload-file-content.php @@ -145,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 { @@ -200,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 */ @@ -334,7 +326,7 @@ public function test_skipped_pdf_preview_does_not_leak_into_later_requests(): vo } /** - * Cover art embedded in a visitor's audio file is not saved, so it never reaches an image editor. + * Cover art in a visitor's audio file is not saved. * * @return void */ @@ -342,7 +334,7 @@ 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( 'application/pdf', "%PDF-1.4\n" ), 'visitor.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.' ); From 2ccf639118cb7747b77efda91f77063cf80e5038 Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Wed, 21 Oct 2026 12:40:31 +0200 Subject: [PATCH 3/9] Limit bidirectional links to valid submitted targets Submitted relationship values only link back to objects that are valid choices for the field, or that the submitting user can edit. Revisions never link back. New filter: scf/bidirectional/allow_target. Co-Authored-By: Claude Opus 5.5 --- includes/acf-bidirectional-functions.php | 89 +++++ includes/acf-form-functions.php | 8 +- readme.txt | 1 + .../test-acf-bidirectional-functions.php | 342 ++++++++++++++++++ 4 files changed, 439 insertions(+), 1 deletion(-) diff --git a/includes/acf-bidirectional-functions.php b/includes/acf-bidirectional-functions.php index 068407f7..5eaf8bc9 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'] && 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..b49905de 100644 --- a/includes/acf-form-functions.php +++ b/includes/acf-form-functions.php @@ -194,7 +194,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', true ); + 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/readme.txt b/readme.txt index 67dcc85f..e4396955 100644 --- a/readme.txt +++ b/readme.txt @@ -56,6 +56,7 @@ This plugin builds upon and is a fork of the previous work done by the contribut *Security* +- Submitted relationship values only link back to objects that are valid choices for the field, or that the submitting user can edit. 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`. = 6.9.6 = diff --git a/tests/php/includes/functions/test-acf-bidirectional-functions.php b/tests/php/includes/functions/test-acf-bidirectional-functions.php index a18a55bb..d976679f 100644 --- a/tests/php/includes/functions/test-acf-bidirectional-functions.php +++ b/tests/php/includes/functions/test-acf-bidirectional-functions.php @@ -884,4 +884,346 @@ 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. + acf_save_post( $post_id, $values ); + $_POST = $original_post; + } + + /** + * 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->register_relational_field_type( 'relationship' ); + 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', + ) + ); + 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(), + ) + ); + + $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' ); + } } From ff769dc29b11b6bf0a08feb8433dcdbe5e71641f Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Wed, 21 Oct 2026 12:40:32 +0200 Subject: [PATCH 4/9] Reduce password-protected references in field output Password-protected posts referenced by Post Object or Relationship fields are only expanded in REST responses for users who can edit them. Block bindings only expand referenced content and user details the viewer can read, and values of password-protected posts stay hidden in the shortcode, block bindings and JSON-LD output until the password is entered. Co-Authored-By: Claude Opus 5.5 --- includes/Blocks/Bindings.php | 10 +- includes/api/api-template.php | 4 + includes/rest-api/acf-rest-api-functions.php | 4 + readme.txt | 3 + src/AI/GEO/Outputs/Blocks.php | 8 + src/AI/GEO/Outputs/Posts.php | 7 + .../includes/blocks/test-blocks-bindings.php | 110 ++++++++++- .../test-acf-rest-reference-sanitization.php | 53 ++++++ .../test-protected-content-output.php | 177 ++++++++++++++++++ 9 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 tests/php/includes/test-protected-content-output.php diff --git a/includes/Blocks/Bindings.php b/includes/Blocks/Bindings.php index a95aecce..349d8414 100644 --- a/includes/Blocks/Bindings.php +++ b/includes/Blocks/Bindings.php @@ -57,6 +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 { + $post_id = acf_get_valid_post_id( false ); + $decoded = acf_decode_post_id( $post_id ); + if ( 'post' === $decoded['type'] && post_password_required( $decoded['id'] ) ) { + return ''; + } + $field = get_field_object( $source_attrs['key'], false, true, true, true ); if ( ! $field ) { @@ -79,7 +85,9 @@ 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 ); + $field_value = acf_rest_sanitize_reference_data( $field_value, $raw_value, $field ); switch ( $attribute_name ) { case 'id': diff --git a/includes/api/api-template.php b/includes/api/api-template.php index e92cd9f7..bcef6447 100644 --- a/includes/api/api-template.php +++ b/includes/api/api-template.php @@ -1049,6 +1049,10 @@ function acf_shortcode( $atts ) { // If we've decoded to a post, ensure the post is publicly visible. if ( $decoded_post_id['type'] === 'post' ) { + if ( post_password_required( $decoded_post_id['id'] ) ) { + return; + } + 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 ) ) { if ( is_preview() ) { return apply_filters( 'acf/shortcode/post_not_public_message', esc_html__( '[The ACF shortcode cannot display fields from non-public posts]', 'secure-custom-fields' ) ); diff --git a/includes/rest-api/acf-rest-api-functions.php b/includes/rest-api/acf-rest-api-functions.php index c0775379..7b5f08b4 100644 --- a/includes/rest-api/acf-rest-api-functions.php +++ b/includes/rest-api/acf-rest-api-functions.php @@ -523,6 +523,10 @@ function acf_rest_reference_is_exposable( $target_id, $field ) { if ( method_exists( $controller, 'check_read_permission' ) ) { $exposable = (bool) $controller->check_read_permission( $post ); } + + if ( $exposable && '' !== $post->post_password && ! current_user_can( 'edit_post', $post->ID ) ) { + $exposable = false; + } } } } diff --git a/readme.txt b/readme.txt index e4396955..804efe23 100644 --- a/readme.txt +++ b/readme.txt @@ -58,6 +58,9 @@ This plugin builds upon and is a fork of the previous work done by the contribut - Submitted relationship values only link back to objects that are valid choices for the field, or that the submitting user can edit. 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 in REST responses for users who can edit them. +- Field values shown through block bindings only expand referenced content and user details the viewer can read. +- Field values of password-protected posts stay hidden in the shortcode, block bindings and JSON-LD output until the password is entered. = 6.9.6 = *Release Date 16th October 2026* diff --git a/src/AI/GEO/Outputs/Blocks.php b/src/AI/GEO/Outputs/Blocks.php index def90ae1..2a339fc4 100644 --- a/src/AI/GEO/Outputs/Blocks.php +++ b/src/AI/GEO/Outputs/Blocks.php @@ -151,6 +151,14 @@ public function output_block_jsonld_data( $block, $content, $is_preview, $post_i echo '\n"; } + $decoded_post_id = acf_decode_post_id( $post_id ); + if ( 'post' === $decoded_post_id['type'] && post_password_required( $decoded_post_id['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/blocks/test-blocks-bindings.php b/tests/php/includes/blocks/test-blocks-bindings.php index 6cdb5405..56455277 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,87 @@ 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 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 ); + } + + /** + * 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' ); + $this->assertSame( 'Locked value', $bindings->get_value( array( 'key' => 'field_bindings_text' ), $block, 'content' ) ); + } + /** * Test missing or invalid source attributes. */ 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..78432a54 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,42 @@ 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_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 +423,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..a8ccea72 --- /dev/null +++ b/tests/php/includes/test-protected-content-output.php @@ -0,0 +1,177 @@ +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', + ), + ), + '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_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' ); + $this->assertSame( 'Locked value', acf_shortcode( $atts ) ); + } + + /** + * 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' ); + $this->assertStringContainsString( 'Locked value', $this->render_post_jsonld( $this->protected_id ) ); + } + + /** + * 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; + } +} From fb2731cd579c3cd9763539048417e21398bbdf6e Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Wed, 21 Oct 2026 12:41:20 +0200 Subject: [PATCH 5/9] Prepare 6.9.7 release Add the 6.9.7 upgrade notice, update the release date and version to 6.9.7, set the Stable tag to 6.9.7, and regenerate the code reference. Co-Authored-By: Claude Opus 5.5 --- docs/code-reference/META.md | 2 ++ .../code-reference/acf-bidirectional-functions-file.md | 10 ++++++++++ readme.txt | 7 +++++-- secure-custom-fields.php | 4 ++-- 4 files changed, 19 insertions(+), 4 deletions(-) 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/readme.txt b/readme.txt index 804efe23..6a405aa7 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 @@ -52,7 +52,7 @@ This plugin builds upon and is a fork of the previous work done by the contribut == Changelog == = 6.9.7 = -*Release Date 20th October 2026* +*Release Date 21st October 2026* *Security* @@ -474,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. From 1ed37c112cd1220cce7469deec629705af34588f Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Wed, 21 Oct 2026 14:43:09 +0200 Subject: [PATCH 6/9] Tidy protected content checks and reference expansion Share one helper for the password gate across output surfaces, apply it to the REST field output, and let block bindings expand references by front-end readability. Reuse REST controllers for reference checks and simplify the upload preview filter. Co-Authored-By: Claude Fable 5.1 --- docs/code-reference/api/api-helpers-file.md | 9 ++ .../rest-api/acf-rest-api-functions-file.md | 11 ++ .../rest-api/class-acf-rest-api-file.md | 10 ++ includes/Blocks/Bindings.php | 39 ++++++- includes/acf-bidirectional-functions.php | 2 +- includes/api/api-helpers.php | 26 +++-- includes/api/api-template.php | 8 +- includes/rest-api/acf-rest-api-functions.php | 27 ++++- includes/rest-api/class-acf-rest-api.php | 26 +++++ readme.txt | 4 +- src/AI/GEO/Outputs/Blocks.php | 3 +- .../includes/blocks/test-blocks-bindings.php | 82 ++++++++++++- .../includes/rest-api/test-acf-rest-api.php | 109 ++++++++++++++++++ .../test-acf-rest-reference-sanitization.php | 4 + .../test-protected-content-output.php | 10 +- 15 files changed, 346 insertions(+), 24 deletions(-) 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/acf-rest-api-functions-file.md b/docs/code-reference/rest-api/acf-rest-api-functions-file.md index 08c526ae..7b865fad 100644 --- a/docs/code-reference/rest-api/acf-rest-api-functions-file.md +++ b/docs/code-reference/rest-api/acf-rest-api-functions-file.md @@ -140,6 +140,17 @@ controller for a post type can re-expose targets via the * @param array $field The field array (context for filter subscribers). * @return boolean +## `scf_rest_get_reference_controller()` + +Returns a shared REST controller instance for checking reference targets. + +* Reference checks can run for every referenced post on a page, so the +controller for each post type is only instantiated once per request. +* @since SCF 6.9.7 +* @param string $controller_class The controller class name. +* @param string $post_type The post type the controller handles. +* @return object + ## `scf_rest_format_standard_value()` Applies standard field formatting while keeping User values and references to 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 349d8414..5dd3af52 100644 --- a/includes/Blocks/Bindings.php +++ b/includes/Blocks/Bindings.php @@ -58,12 +58,11 @@ public function get_value( array $source_attrs, \WP_Block $block_instance, strin $value = ''; } else { $post_id = acf_get_valid_post_id( false ); - $decoded = acf_decode_post_id( $post_id ); - if ( 'post' === $decoded['type'] && post_password_required( $decoded['id'] ) ) { + if ( scf_post_password_required( $post_id ) ) { return ''; } - $field = get_field_object( $source_attrs['key'], false, true, true, true ); + $field = get_field_object( $source_attrs['key'], $post_id, true, true, true ); if ( ! $field ) { return ''; @@ -87,7 +86,10 @@ public function get_value( array $source_attrs, \WP_Block $block_instance, strin $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' ), 5, 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' ), 5 ); switch ( $attribute_name ) { case 'id': @@ -130,4 +132,35 @@ 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 ); } + + /** + * 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. + * + * @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 5eaf8bc9..163a71bd 100644 --- a/includes/acf-bidirectional-functions.php +++ b/includes/acf-bidirectional-functions.php @@ -35,7 +35,7 @@ function acf_update_bidirectional_values( $target_item_ids, $post_id, $field, $t } $decoded_origin = acf_decode_post_id( $post_id ); - if ( 'post' === $decoded_origin['type'] && wp_is_post_revision( (int) $decoded_origin['id'] ) ) { + if ( 'post' === $decoded_origin['type'] && (int) $decoded_origin['id'] > 0 && wp_is_post_revision( (int) $decoded_origin['id'] ) ) { return; } diff --git a/includes/api/api-helpers.php b/includes/api/api-helpers.php index 2940b9f9..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'] ); +} + /** @@ -2552,18 +2567,15 @@ function acf_upload_file( $uploaded_file ) { * @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_preview_sizes = static function () { - return array(); - }; - $no_cover_art = static function ( $metadata ) { + $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', $no_preview_sizes, PHP_INT_MAX ); + 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 ); } @@ -2572,7 +2584,7 @@ function acf_upload_file( $uploaded_file ) { wp_update_attachment_metadata( $id, wp_generate_attachment_metadata( $id, $file ) ); if ( $skip_previews ) { - remove_filter( 'fallback_intermediate_image_sizes', $no_preview_sizes, PHP_INT_MAX ); + 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 ); } diff --git a/includes/api/api-template.php b/includes/api/api-template.php index bcef6447..e819ec50 100644 --- a/includes/api/api-template.php +++ b/includes/api/api-template.php @@ -1047,12 +1047,12 @@ 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 ( post_password_required( $decoded_post_id['id'] ) ) { - return; - } - 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 ) ) { if ( is_preview() ) { return apply_filters( 'acf/shortcode/post_not_public_message', esc_html__( '[The ACF shortcode cannot display fields from non-public posts]', 'secure-custom-fields' ) ); diff --git a/includes/rest-api/acf-rest-api-functions.php b/includes/rest-api/acf-rest-api-functions.php index 7b5f08b4..178fd3e1 100644 --- a/includes/rest-api/acf-rest-api-functions.php +++ b/includes/rest-api/acf-rest-api-functions.php @@ -518,13 +518,13 @@ function acf_rest_reference_is_exposable( $target_id, $field ) { : 'WP_REST_Posts_Controller'; if ( class_exists( $controller_class ) ) { - $controller = new $controller_class( $post->post_type ); + $controller = scf_rest_get_reference_controller( $controller_class, $post->post_type ); if ( method_exists( $controller, 'check_read_permission' ) ) { $exposable = (bool) $controller->check_read_permission( $post ); } - if ( $exposable && '' !== $post->post_password && ! current_user_can( 'edit_post', $post->ID ) ) { + if ( $exposable && post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ) ) { $exposable = false; } } @@ -545,6 +545,29 @@ function acf_rest_reference_is_exposable( $target_id, $field ) { return (bool) apply_filters( 'acf/rest/expose_reference_target', $exposable, $target_id, $field ); } +/** + * Returns a shared REST controller instance for checking reference targets. + * + * Reference checks can run for every referenced post on a page, so the + * controller for each post type is only instantiated once per request. + * + * @since SCF 6.9.7 + * + * @param string $controller_class The controller class name. + * @param string $post_type The post type the controller handles. + * @return object + */ +function scf_rest_get_reference_controller( $controller_class, $post_type ) { + static $controllers = array(); + + $key = $controller_class . ':' . $post_type; + if ( ! isset( $controllers[ $key ] ) ) { + $controllers[ $key ] = new $controller_class( $post_type ); + } + + return $controllers[ $key ]; +} + /** * Applies standard field formatting while keeping User values and references to * resources the current user cannot read safe for REST output. 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 6a405aa7..b88a89d7 100644 --- a/readme.txt +++ b/readme.txt @@ -58,9 +58,9 @@ This plugin builds upon and is a fork of the previous work done by the contribut - Submitted relationship values only link back to objects that are valid choices for the field, or that the submitting user can edit. 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 in REST responses for users who can edit them. +- 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. -- Field values of password-protected posts stay hidden in the shortcode, block bindings and JSON-LD output until the password is entered. +- 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* diff --git a/src/AI/GEO/Outputs/Blocks.php b/src/AI/GEO/Outputs/Blocks.php index 2a339fc4..5e807a80 100644 --- a/src/AI/GEO/Outputs/Blocks.php +++ b/src/AI/GEO/Outputs/Blocks.php @@ -151,8 +151,7 @@ public function output_block_jsonld_data( $block, $content, $is_preview, $post_i echo '\n"; } - $decoded_post_id = acf_decode_post_id( $post_id ); - if ( 'post' === $decoded_post_id['type'] && post_password_required( $decoded_post_id['id'] ) ) { + if ( scf_post_password_required( $post_id ) ) { if ( $debug ) { echo "\n"; } diff --git a/tests/php/includes/blocks/test-blocks-bindings.php b/tests/php/includes/blocks/test-blocks-bindings.php index 56455277..f9bacebb 100644 --- a/tests/php/includes/blocks/test-blocks-bindings.php +++ b/tests/php/includes/blocks/test-blocks-bindings.php @@ -316,6 +316,83 @@ public function test_get_value_reduces_references_the_viewer_cannot_read() { $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 ); + } + + /** + * 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. */ @@ -359,7 +436,10 @@ public function test_get_value_hides_values_of_password_protected_posts() { $this->assertSame( '', $bindings->get_value( array( 'key' => 'field_bindings_text' ), $block, 'content' ) ); add_filter( 'post_password_required', '__return_false' ); - $this->assertSame( 'Locked value', $bindings->get_value( array( 'key' => 'field_bindings_text' ), $block, 'content' ) ); + $unlocked = $bindings->get_value( array( 'key' => 'field_bindings_text' ), $block, 'content' ); + remove_filter( 'post_password_required', '__return_false' ); + + $this->assertSame( 'Locked value', $unlocked ); } /** 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 78432a54..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 @@ -118,6 +118,10 @@ public function test_is_exposable_hides_password_protected_posts() { $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 ); } diff --git a/tests/php/includes/test-protected-content-output.php b/tests/php/includes/test-protected-content-output.php index a8ccea72..d7b9f009 100644 --- a/tests/php/includes/test-protected-content-output.php +++ b/tests/php/includes/test-protected-content-output.php @@ -115,7 +115,10 @@ public function test_shortcode_hides_password_protected_post_fields() { $this->assertEmpty( acf_shortcode( $atts ) ); add_filter( 'post_password_required', '__return_false' ); - $this->assertSame( 'Locked value', acf_shortcode( $atts ) ); + $unlocked = acf_shortcode( $atts ); + remove_filter( 'post_password_required', '__return_false' ); + + $this->assertSame( 'Locked value', $unlocked ); } /** @@ -134,7 +137,10 @@ 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' ); - $this->assertStringContainsString( 'Locked value', $this->render_post_jsonld( $this->protected_id ) ); + $unlocked = $this->render_post_jsonld( $this->protected_id ); + remove_filter( 'post_password_required', '__return_false' ); + + $this->assertStringContainsString( 'Locked value', $unlocked ); } /** From 10228885df85f09dd208c604ace806d55bd1998b Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Wed, 21 Oct 2026 15:16:17 +0200 Subject: [PATCH 7/9] Shortcode fix. --- includes/Blocks/Bindings.php | 25 +++++++++ includes/api/api-template.php | 13 ++++- readme.txt | 2 +- .../includes/blocks/test-blocks-bindings.php | 1 + .../test-protected-content-output.php | 51 +++++++++++++++++++ 5 files changed, 89 insertions(+), 3 deletions(-) diff --git a/includes/Blocks/Bindings.php b/includes/Blocks/Bindings.php index 5dd3af52..a20b351f 100644 --- a/includes/Blocks/Bindings.php +++ b/includes/Blocks/Bindings.php @@ -90,6 +90,7 @@ public function get_value( array $source_attrs, \WP_Block $block_instance, strin add_filter( 'acf/rest/expose_reference_target', array( $this, 'expose_reference_target' ), 5, 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' ), 5 ); + $field_value = $this->strip_post_passwords( $field_value ); switch ( $attribute_name ) { case 'id': @@ -133,6 +134,30 @@ 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. * diff --git a/includes/api/api-template.php b/includes/api/api-template.php index e819ec50..77be196a 100644 --- a/includes/api/api-template.php +++ b/includes/api/api-template.php @@ -1071,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'; @@ -1106,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/readme.txt b/readme.txt index b88a89d7..393cdbed 100644 --- a/readme.txt +++ b/readme.txt @@ -59,7 +59,7 @@ This plugin builds upon and is a fork of the previous work done by the contribut - Submitted relationship values only link back to objects that are valid choices for the field, or that the submitting user can edit. 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. +- 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 = diff --git a/tests/php/includes/blocks/test-blocks-bindings.php b/tests/php/includes/blocks/test-blocks-bindings.php index f9bacebb..24b1785f 100644 --- a/tests/php/includes/blocks/test-blocks-bindings.php +++ b/tests/php/includes/blocks/test-blocks-bindings.php @@ -342,6 +342,7 @@ public function test_get_value_expands_unlocked_password_protected_references() remove_filter( 'post_password_required', '__return_false' ); $this->assertStringContainsString( 'Protected body', $value ); + $this->assertStringNotContainsString( 'bindings-secret', $value ); } /** diff --git a/tests/php/includes/test-protected-content-output.php b/tests/php/includes/test-protected-content-output.php index d7b9f009..f1d80193 100644 --- a/tests/php/includes/test-protected-content-output.php +++ b/tests/php/includes/test-protected-content-output.php @@ -42,6 +42,13 @@ class Test_Protected_Content_Output extends BaseTestCase { public function set_up() { parent::set_up(); + if ( ! class_exists( 'acf_field_user' ) ) { + acf_include( 'includes/fields/class-acf-field-user.php' ); + } + if ( ! has_filter( 'acf/format_value/type=user' ) ) { + acf_register_field_type( 'acf_field_user' ); + } + $this->public_id = wp_insert_post( array( 'post_title' => 'Public post', @@ -67,6 +74,15 @@ public function set_up() { '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( @@ -95,6 +111,7 @@ 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 @@ -121,6 +138,40 @@ public function test_shortcode_hides_password_protected_post_fields() { $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. */ From f78ae06ee1153551a67cf3c68ca7052e19cfad43 Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Wed, 21 Oct 2026 16:03:52 +0200 Subject: [PATCH 8/9] Small fixes post ultra-review. --- .../rest-api/acf-rest-api-functions-file.md | 11 ----- includes/Blocks/Bindings.php | 6 ++- includes/rest-api/acf-rest-api-functions.php | 41 +++---------------- .../includes/blocks/test-blocks-bindings.php | 8 ++++ 4 files changed, 18 insertions(+), 48 deletions(-) diff --git a/docs/code-reference/rest-api/acf-rest-api-functions-file.md b/docs/code-reference/rest-api/acf-rest-api-functions-file.md index 7b865fad..08c526ae 100644 --- a/docs/code-reference/rest-api/acf-rest-api-functions-file.md +++ b/docs/code-reference/rest-api/acf-rest-api-functions-file.md @@ -140,17 +140,6 @@ controller for a post type can re-expose targets via the * @param array $field The field array (context for filter subscribers). * @return boolean -## `scf_rest_get_reference_controller()` - -Returns a shared REST controller instance for checking reference targets. - -* Reference checks can run for every referenced post on a page, so the -controller for each post type is only instantiated once per request. -* @since SCF 6.9.7 -* @param string $controller_class The controller class name. -* @param string $post_type The post type the controller handles. -* @return object - ## `scf_rest_format_standard_value()` Applies standard field formatting while keeping User values and references to diff --git a/includes/Blocks/Bindings.php b/includes/Blocks/Bindings.php index a20b351f..e83a1034 100644 --- a/includes/Blocks/Bindings.php +++ b/includes/Blocks/Bindings.php @@ -87,9 +87,9 @@ public function get_value( array $source_attrs, \WP_Block $block_instance, strin $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' ), 5, 2 ); + 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' ), 5 ); + 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 ) { @@ -164,6 +164,8 @@ private function strip_post_passwords( $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 * diff --git a/includes/rest-api/acf-rest-api-functions.php b/includes/rest-api/acf-rest-api-functions.php index 178fd3e1..f422827e 100644 --- a/includes/rest-api/acf-rest-api-functions.php +++ b/includes/rest-api/acf-rest-api-functions.php @@ -513,20 +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 = scf_rest_get_reference_controller( $controller_class, $post->post_type ); - - if ( method_exists( $controller, 'check_read_permission' ) ) { - $exposable = (bool) $controller->check_read_permission( $post ); - } + if ( $controller && 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; - } + if ( $exposable && post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ) ) { + $exposable = false; } } } @@ -545,29 +539,6 @@ function acf_rest_reference_is_exposable( $target_id, $field ) { return (bool) apply_filters( 'acf/rest/expose_reference_target', $exposable, $target_id, $field ); } -/** - * Returns a shared REST controller instance for checking reference targets. - * - * Reference checks can run for every referenced post on a page, so the - * controller for each post type is only instantiated once per request. - * - * @since SCF 6.9.7 - * - * @param string $controller_class The controller class name. - * @param string $post_type The post type the controller handles. - * @return object - */ -function scf_rest_get_reference_controller( $controller_class, $post_type ) { - static $controllers = array(); - - $key = $controller_class . ':' . $post_type; - if ( ! isset( $controllers[ $key ] ) ) { - $controllers[ $key ] = new $controller_class( $post_type ); - } - - return $controllers[ $key ]; -} - /** * Applies standard field formatting while keeping User values and references to * resources the current user cannot read safe for REST output. diff --git a/tests/php/includes/blocks/test-blocks-bindings.php b/tests/php/includes/blocks/test-blocks-bindings.php index 24b1785f..4fe2ba07 100644 --- a/tests/php/includes/blocks/test-blocks-bindings.php +++ b/tests/php/includes/blocks/test-blocks-bindings.php @@ -415,6 +415,14 @@ public function test_get_value_keeps_readable_references() { $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 ); } /** From 4c8bfb3cd50ff8adc6a82672241cb4b44f4a6f3f Mon Sep 17 00:00:00 2001 From: Carlos Bravo Date: Wed, 21 Oct 2026 16:30:33 +0200 Subject: [PATCH 9/9] More small changes --- includes/acf-form-functions.php | 12 +- readme.txt | 2 +- .../test-acf-bidirectional-functions.php | 130 +++++++++++++++--- 3 files changed, 119 insertions(+), 25 deletions(-) diff --git a/includes/acf-form-functions.php b/includes/acf-form-functions.php index b49905de..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; @@ -195,7 +203,7 @@ function _acf_do_save_post( $post_id = 0 ) { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified elsewhere. if ( ! empty( $_POST['acf'] ) ) { $was_saving_submitted = acf_get_data( 'scf_saving_submitted_values' ); - acf_set_data( 'scf_saving_submitted_values', true ); + 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 { diff --git a/readme.txt b/readme.txt index 393cdbed..27733e6b 100644 --- a/readme.txt +++ b/readme.txt @@ -56,7 +56,7 @@ This plugin builds upon and is a fork of the previous work done by the contribut *Security* -- Submitted relationship values only link back to objects that are valid choices for the field, or that the submitting user can edit. Revisions never link back. New filter: `scf/bidirectional/allow_target`. +- 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. diff --git a/tests/php/includes/functions/test-acf-bidirectional-functions.php b/tests/php/includes/functions/test-acf-bidirectional-functions.php index d976679f..ddbb13f6 100644 --- a/tests/php/includes/functions/test-acf-bidirectional-functions.php +++ b/tests/php/includes/functions/test-acf-bidirectional-functions.php @@ -907,10 +907,116 @@ private function register_relational_field_type( $type ) { */ private function save_submitted_values( $values, $post_id ) { $original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Test fixture. - acf_save_post( $post_id, $values ); + $_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. */ @@ -978,7 +1084,7 @@ public function test_submitted_user_outside_allowed_roles_is_not_linked() { */ public function test_submitted_unreadable_post_is_not_linked() { acf_update_setting( 'enable_bidirection', true ); - $this->register_relational_field_type( 'relationship' ); + $this->add_page_relationship_fields(); wp_set_current_user( 0 ); $source_id = wp_insert_post( @@ -1007,26 +1113,6 @@ public function test_submitted_unreadable_post_is_not_linked() { 'post_status' => 'publish', ) ); - 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(), - ) - ); $this->save_submitted_values( array( 'field_bidi_post_source' => array( (string) $private_id, (string) $public_id, (string) $other_id ) ), $source_id );