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 );
}