diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php
index 8ea0b3680291c..29a93c0309fb8 100644
--- a/src/wp-admin/includes/schema.php
+++ b/src/wp-admin/includes/schema.php
@@ -431,6 +431,7 @@ function populate_options( array $options = array() ) {
'default_comment_status' => 'open',
'default_ping_status' => 'open',
'default_pingback_flag' => 1,
+ 'auto_approve_self_pingbacks' => 0,
'posts_per_page' => 10,
/* translators: Default date format, see https://www.php.net/manual/datetime.format.php */
'date_format' => __( 'F j, Y' ),
diff --git a/src/wp-admin/options-discussion.php b/src/wp-admin/options-discussion.php
index 0c350475fe176..40b47b98b8a89 100644
--- a/src/wp-admin/options-discussion.php
+++ b/src/wp-admin/options-discussion.php
@@ -176,6 +176,8 @@
+
+
diff --git a/src/wp-admin/options.php b/src/wp-admin/options.php
index b45cbb00387ce..f423a0dfc77a1 100644
--- a/src/wp-admin/options.php
+++ b/src/wp-admin/options.php
@@ -109,6 +109,7 @@
'comment_moderation',
'require_name_email',
'comment_previously_approved',
+ 'auto_approve_self_pingbacks',
'comment_max_links',
'moderation_keys',
'disallowed_keys',
diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php
index b93908adc0519..35a7701582a82 100644
--- a/src/wp-includes/comment.php
+++ b/src/wp-includes/comment.php
@@ -1349,6 +1349,53 @@ function wp_check_comment_data( $comment_data ) {
return apply_filters( 'pre_comment_approved', $approved, $comment_data );
}
+/**
+ * Automatically approves a pingback that originates from this site.
+ *
+ * A self-pingback is generated when one post on the site links to another post
+ * on the same site. When the `auto_approve_self_pingbacks` option is enabled,
+ * such pingbacks are approved instead of being held for moderation.
+ *
+ * Only pending pingbacks are affected; comments already flagged as approved,
+ * spam, or trash (or returning a WP_Error) are left untouched.
+ *
+ * The source is identified from `comment_author_url`. For a pingback this is the
+ * source URL that core has already fetched and verified actually links to the
+ * target post, so a URL resolving to a local post indicates genuine local content.
+ *
+ * @since 7.1.0
+ *
+ * @param int|string|WP_Error $approved The approval status. Accepts 1, 0, 'spam', 'trash',
+ * or WP_Error.
+ * @param array $comment_data Comment data.
+ * @return int|string|WP_Error The (possibly updated) approval status.
+ */
+function wp_auto_approve_self_pingback( $approved, $comment_data ) {
+ // Only act on pending comments; leave approved, spam, trash, and errors alone.
+ if ( 0 !== $approved && '0' !== $approved ) {
+ return $approved;
+ }
+
+ if ( ! get_option( 'auto_approve_self_pingbacks' ) ) {
+ return $approved;
+ }
+
+ if ( empty( $comment_data['comment_type'] ) || 'pingback' !== $comment_data['comment_type'] ) {
+ return $approved;
+ }
+
+ if ( empty( $comment_data['comment_author_url'] ) ) {
+ return $approved;
+ }
+
+ // The pingback source URL resolves to a post on this site, so treat it as a self-pingback.
+ if ( url_to_postid( $comment_data['comment_author_url'] ) > 0 ) {
+ return 1;
+ }
+
+ return $approved;
+}
+
/**
* Checks if a comment contains disallowed characters or words.
*
diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php
index 5581828a10b61..83e4aeda6d78b 100644
--- a/src/wp-includes/default-filters.php
+++ b/src/wp-includes/default-filters.php
@@ -321,6 +321,7 @@
add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
add_filter( 'comment_flood_filter', 'wp_throttle_comment_flood', 10, 3 );
add_filter( 'pre_comment_content', 'wp_rel_ugc', 15 );
+add_filter( 'pre_comment_approved', 'wp_auto_approve_self_pingback', 10, 2 );
add_filter( 'comment_email', 'antispambot' );
add_filter( 'option_tag_base', '_wp_filter_taxonomy_base' );
add_filter( 'option_category_base', '_wp_filter_taxonomy_base' );
diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php
index 7979c119a986f..462d110210910 100644
--- a/src/wp-includes/option.php
+++ b/src/wp-includes/option.php
@@ -2943,6 +2943,17 @@ function register_initial_settings() {
)
);
+ register_setting(
+ 'discussion',
+ 'auto_approve_self_pingbacks',
+ array(
+ 'show_in_rest' => true,
+ 'type' => 'boolean',
+ 'label' => __( 'Automatically approve pingbacks from this site' ),
+ 'description' => __( 'Automatically approve pingbacks generated by links between posts on this site.' ),
+ )
+ );
+
register_setting(
'discussion',
'default_comment_status',
diff --git a/tests/phpunit/tests/comment/autoApproveSelfPingback.php b/tests/phpunit/tests/comment/autoApproveSelfPingback.php
new file mode 100644
index 0000000000000..94f52c7e79a00
--- /dev/null
+++ b/tests/phpunit/tests/comment/autoApproveSelfPingback.php
@@ -0,0 +1,122 @@
+post->create();
+ }
+
+ /**
+ * Builds comment data for a pingback whose source URL points to a local post.
+ *
+ * @param string $comment_type Comment type. Default 'pingback'.
+ * @return array Comment data.
+ */
+ private function get_self_pingback_data( $comment_type = 'pingback' ) {
+ return array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_author' => 'Self Site',
+ 'comment_author_url' => get_permalink( self::$post_id ),
+ 'comment_content' => 'A link from this site.',
+ 'comment_type' => $comment_type,
+ );
+ }
+
+ public function test_approves_pending_self_pingback_when_option_enabled() {
+ update_option( 'auto_approve_self_pingbacks', 1 );
+
+ $approved = wp_auto_approve_self_pingback( 0, $this->get_self_pingback_data() );
+
+ $this->assertSame( 1, $approved );
+ }
+
+ public function test_leaves_self_pingback_pending_when_option_disabled() {
+ update_option( 'auto_approve_self_pingbacks', 0 );
+
+ $approved = wp_auto_approve_self_pingback( 0, $this->get_self_pingback_data() );
+
+ $this->assertSame( 0, $approved );
+ }
+
+ public function test_ignores_non_pingback_comment_types() {
+ update_option( 'auto_approve_self_pingbacks', 1 );
+
+ $approved = wp_auto_approve_self_pingback( 0, $this->get_self_pingback_data( 'comment' ) );
+
+ $this->assertSame( 0, $approved );
+ }
+
+ public function test_does_not_approve_pingback_from_external_url() {
+ update_option( 'auto_approve_self_pingbacks', 1 );
+
+ $data = $this->get_self_pingback_data();
+ $data['comment_author_url'] = 'https://example.org/some-other-post/';
+
+ $approved = wp_auto_approve_self_pingback( 0, $data );
+
+ $this->assertSame( 0, $approved );
+ }
+
+ /**
+ * Spam, trash, approved, and error verdicts must never be downgraded or overridden.
+ *
+ * @dataProvider data_non_pending_statuses
+ *
+ * @param mixed $status A non-pending approval status.
+ */
+ public function test_does_not_override_non_pending_status( $status ) {
+ update_option( 'auto_approve_self_pingbacks', 1 );
+
+ $approved = wp_auto_approve_self_pingback( $status, $this->get_self_pingback_data() );
+
+ $this->assertSame( $status, $approved );
+ }
+
+ public function data_non_pending_statuses() {
+ return array(
+ 'already approved' => array( 1 ),
+ 'spam' => array( 'spam' ),
+ 'trash' => array( 'trash' ),
+ );
+ }
+
+ public function test_does_not_override_wp_error_status() {
+ update_option( 'auto_approve_self_pingbacks', 1 );
+
+ $error = new WP_Error( 'disallowed', 'Comment is not allowed.' );
+ $approved = wp_auto_approve_self_pingback( $error, $this->get_self_pingback_data() );
+
+ $this->assertSame( $error, $approved );
+ }
+
+ /**
+ * The callback must be wired to the `pre_comment_approved` filter by default,
+ * so a pending self-pingback is approved through the real filter chain.
+ */
+ public function test_filter_is_registered_and_approves_self_pingback() {
+ $this->assertSame(
+ 10,
+ has_filter( 'pre_comment_approved', 'wp_auto_approve_self_pingback' ),
+ 'wp_auto_approve_self_pingback should be hooked to pre_comment_approved at priority 10.'
+ );
+
+ update_option( 'auto_approve_self_pingbacks', 1 );
+
+ $approved = apply_filters( 'pre_comment_approved', 0, $this->get_self_pingback_data() );
+
+ $this->assertSame( 1, $approved );
+ }
+}
diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php
index e8f90b53f20f1..c98d09a025a98 100644
--- a/tests/phpunit/tests/rest-api/rest-settings-controller.php
+++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php
@@ -117,6 +117,7 @@ public function test_get_items() {
'page_on_front',
'page_for_posts',
'default_ping_status',
+ 'auto_approve_self_pingbacks',
'default_comment_status',
'site_icon', // Registered in wp-includes/blocks/site-logo.php
);
diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js
index fa03d9751fe99..52daf6ed41ea1 100644
--- a/tests/qunit/fixtures/wp-api-generated.js
+++ b/tests/qunit/fixtures/wp-api-generated.js
@@ -11193,6 +11193,12 @@ mockedApiResponse.Schema = {
],
"required": false
},
+ "auto_approve_self_pingbacks": {
+ "title": "Automatically approve pingbacks from this site",
+ "description": "Automatically approve pingbacks generated by links between posts on this site.",
+ "type": "boolean",
+ "required": false
+ },
"default_comment_status": {
"title": "Allow comments on new posts",
"description": "Allow people to submit comments on new posts.",
@@ -14671,6 +14677,7 @@ mockedApiResponse.settings = {
"page_on_front": 0,
"page_for_posts": 0,
"default_ping_status": "open",
+ "auto_approve_self_pingbacks": false,
"default_comment_status": "open",
"site_logo": null,
"site_icon": 0