Commit 168e3d8cc83 for woocommerce

commit 168e3d8cc83f3b45adad3a35846538b48303f8cb
Author: Mike Jolley <mike.jolley@me.com>
Date:   Thu Jun 18 15:38:06 2026 +0100

    Fix uploads directory Site Health loopback failures (#65757)

    * Fix uploads directory Site Health loopback failures

    * Add changelog entry for Site Health upload check

    * Use closures for Site Health result handlers

    * Cache unverified uploads check for an hour instead of a day

    Addresses review feedback: the unverified Site Health notice could linger
    for a day after the underlying loopback issue was resolved. The confirmed
    protected/unprotected result still caches for a day.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/65723-fix-site-health-upload-loopback b/plugins/woocommerce/changelog/65723-fix-site-health-upload-loopback
new file mode 100644
index 00000000000..22db0c1bb0e
--- /dev/null
+++ b/plugins/woocommerce/changelog/65723-fix-site-health-upload-loopback
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Avoid a critical Site Health warning when WooCommerce cannot verify uploads directory protection.
diff --git a/plugins/woocommerce/src/Internal/Admin/SiteHealth.php b/plugins/woocommerce/src/Internal/Admin/SiteHealth.php
index b222a7987e0..afb182a24f6 100644
--- a/plugins/woocommerce/src/Internal/Admin/SiteHealth.php
+++ b/plugins/woocommerce/src/Internal/Admin/SiteHealth.php
@@ -97,6 +97,13 @@ class SiteHealth {
 		}

 		$data        = $is_good ? $test['good'] : $test['fail'];
+		$label       = is_callable( $data['label'] )
+			? ( $data['label'] )( $context )
+			: $data['label'];
+		$status      = $is_good ? 'good' : ( $data['status'] ?? 'recommended' );
+		$status      = is_callable( $status )
+			? $status( $context )
+			: $status;
 		$description = is_callable( $data['description'] )
 			? ( $data['description'] )( $context )
 			: $data['description'];
@@ -110,8 +117,8 @@ class SiteHealth {
 		}

 		return array(
-			'label'       => $data['label'],
-			'status'      => $is_good ? 'good' : ( $data['status'] ?? 'recommended' ),
+			'label'       => $label,
+			'status'      => $status,
 			'badge'       => array(
 				'label' => 'security' === $test['badge'] ? __( 'Security', 'woocommerce' ) : __( 'Performance', 'woocommerce' ),
 				'color' => 'blue',
@@ -131,6 +138,7 @@ class SiteHealth {
 	 *   - check: callable returning bool (true = good) or array (empty = good, otherwise context for description/actions).
 	 *   - good: result data when the check passes (label, description).
 	 *   - fail: result data when the check fails (status defaults to 'recommended', label, description, optional actions).
+	 *     Label, status, and description may be callables that receive the check context.
 	 *
 	 * @return array<string, array>
 	 */
@@ -166,9 +174,19 @@ class SiteHealth {
 					'description' => __( 'The directory used for downloadable product files is not browsable from the web.', 'woocommerce' ),
 				),
 				'fail'  => array(
-					'status'      => 'critical',
-					'label'       => __( 'WooCommerce uploads directory is browsable from the web', 'woocommerce' ),
-					'description' => __( 'Directory browsing can expose downloadable product files. Configure your web server to prevent directory indexing for the WooCommerce uploads directory.', 'woocommerce' ),
+					'status'      => function ( ?array $context ) {
+						return ! empty( $context['unverified'] ) ? 'recommended' : 'critical';
+					},
+					'label'       => function ( ?array $context ) {
+						return ! empty( $context['unverified'] )
+							? __( 'WooCommerce could not verify uploads directory protection', 'woocommerce' )
+							: __( 'WooCommerce uploads directory is browsable from the web', 'woocommerce' );
+					},
+					'description' => function ( ?array $context ) {
+						return ! empty( $context['unverified'] )
+							? __( 'A loopback request to the WooCommerce uploads directory failed, so WooCommerce could not confirm whether directory browsing is disabled. Check your server configuration or try again later.', 'woocommerce' )
+							: __( 'Directory browsing can expose downloadable product files. Configure your web server to prevent directory indexing for the WooCommerce uploads directory.', 'woocommerce' );
+					},
 					'actions'     => array(
 						array(
 							'url'     => 'https://woocommerce.com/document/digital-downloadable-product-handling/#protecting-your-uploads-directory',
@@ -501,13 +519,17 @@ class SiteHealth {
 	/**
 	 * Check if the WooCommerce uploads directory is protected from directory browsing.
 	 *
-	 * @return bool
+	 * @return bool|array{unverified: true}
 	 */
 	private function is_uploads_directory_protected() {
 		$cache_key = '_woocommerce_upload_directory_status';
 		$status    = get_transient( $cache_key );

 		if ( false !== $status ) {
+			if ( 'unverified' === $status ) {
+				return array( 'unverified' => true );
+			}
+
 			return 'protected' === $status;
 		}

@@ -520,13 +542,15 @@ class SiteHealth {
 		);

 		if ( is_wp_error( $response ) ) {
-			return false;
+			set_transient( $cache_key, 'unverified', HOUR_IN_SECONDS );
+			return array( 'unverified' => true );
 		}

 		$response_code = intval( wp_remote_retrieve_response_code( $response ) );

 		if ( 0 === $response_code ) {
-			return false;
+			set_transient( $cache_key, 'unverified', HOUR_IN_SECONDS );
+			return array( 'unverified' => true );
 		}

 		$response_content = wp_remote_retrieve_body( $response );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php
index d668e10b854..0fef3dced85 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php
@@ -36,12 +36,15 @@ class SiteHealthTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Upload directory protection check fails when the HTTP request fails.
+	 * @testdox Upload directory protection check is inconclusive when the HTTP request fails.
 	 */
-	public function test_uploads_directory_protection_fails_for_http_request_error(): void {
-		$filter_callback = static function ( $_preempt, $_parsed_args, $_url ) {
+	public function test_uploads_directory_protection_is_inconclusive_for_http_request_error(): void {
+		$request_count   = 0;
+		$filter_callback = static function ( $_preempt, $_parsed_args, $_url ) use ( &$request_count ) {
 			unset( $_preempt, $_parsed_args, $_url );

+			++$request_count;
+
 			return new WP_Error( 'http_request_failed', 'Request failed.' );
 		};

@@ -50,20 +53,28 @@ class SiteHealthTest extends WC_Unit_Test_Case {
 		try {
 			$result = $this->sut->run_test( 'woocommerce_uploads_directory_protection' );

-			$this->assertSame( 'critical', $result['status'], 'Request failures should not be reported as protected.' );
-			$this->assertFalse( get_transient( '_woocommerce_upload_directory_status' ), 'Request failures should not be cached.' );
+			$this->assertSame( 'recommended', $result['status'], 'Request failures should not be reported as a confirmed security failure.' );
+			$this->assertSame( 'WooCommerce could not verify uploads directory protection', $result['label'], 'Request failures should report that the result could not be verified.' );
+			$this->assertSame( 'unverified', get_transient( '_woocommerce_upload_directory_status' ), 'Request failures should be cached.' );
+
+			$this->sut->run_test( 'woocommerce_uploads_directory_protection' );
+
+			$this->assertSame( 1, $request_count, 'Cached request failures should not trigger another loopback request.' );
 		} finally {
 			remove_filter( 'pre_http_request', $filter_callback, 10 );
 		}
 	}

 	/**
-	 * @testdox Upload directory protection check fails when the HTTP response code is zero.
+	 * @testdox Upload directory protection check is inconclusive when the HTTP response code is zero.
 	 */
-	public function test_uploads_directory_protection_fails_for_zero_response_code(): void {
-		$filter_callback = static function ( $_preempt, $_parsed_args, $_url ) {
+	public function test_uploads_directory_protection_is_inconclusive_for_zero_response_code(): void {
+		$request_count   = 0;
+		$filter_callback = static function ( $_preempt, $_parsed_args, $_url ) use ( &$request_count ) {
 			unset( $_preempt, $_parsed_args, $_url );

+			++$request_count;
+
 			return array(
 				'headers'  => array(),
 				'body'     => '',
@@ -81,8 +92,45 @@ class SiteHealthTest extends WC_Unit_Test_Case {
 		try {
 			$result = $this->sut->run_test( 'woocommerce_uploads_directory_protection' );

-			$this->assertSame( 'critical', $result['status'], 'Missing response codes should not be reported as protected.' );
-			$this->assertFalse( get_transient( '_woocommerce_upload_directory_status' ), 'Missing response codes should not be cached.' );
+			$this->assertSame( 'recommended', $result['status'], 'Missing response codes should not be reported as a confirmed security failure.' );
+			$this->assertSame( 'WooCommerce could not verify uploads directory protection', $result['label'], 'Missing response codes should report that the result could not be verified.' );
+			$this->assertSame( 'unverified', get_transient( '_woocommerce_upload_directory_status' ), 'Missing response codes should be cached.' );
+
+			$this->sut->run_test( 'woocommerce_uploads_directory_protection' );
+
+			$this->assertSame( 1, $request_count, 'Cached missing response codes should not trigger another loopback request.' );
+		} finally {
+			remove_filter( 'pre_http_request', $filter_callback, 10 );
+		}
+	}
+
+	/**
+	 * @testdox Upload directory protection check is critical when directory browsing is exposed.
+	 */
+	public function test_uploads_directory_protection_is_critical_when_directory_browsing_is_exposed(): void {
+		$filter_callback = static function ( $_preempt, $_parsed_args, $_url ) {
+			unset( $_preempt, $_parsed_args, $_url );
+
+			return array(
+				'headers'  => array(),
+				'body'     => '<html><body>Index of /woocommerce_uploads/</body></html>',
+				'response' => array(
+					'code'    => 200,
+					'message' => 'OK',
+				),
+				'cookies'  => array(),
+				'filename' => null,
+			);
+		};
+
+		add_filter( 'pre_http_request', $filter_callback, 10, 3 );
+
+		try {
+			$result = $this->sut->run_test( 'woocommerce_uploads_directory_protection' );
+
+			$this->assertSame( 'critical', $result['status'], 'Browsable uploads directories should remain critical.' );
+			$this->assertSame( 'WooCommerce uploads directory is browsable from the web', $result['label'], 'Browsable uploads directories should keep the confirmed security failure label.' );
+			$this->assertSame( 'unprotected', get_transient( '_woocommerce_upload_directory_status' ), 'Browsable uploads directory results should be cached as unprotected.' );
 		} finally {
 			remove_filter( 'pre_http_request', $filter_callback, 10 );
 		}