Commit 425bab908c6 for woocommerce

commit 425bab908c673d453827227fa2afd727bfacae07
Author: Darren Ethier <darren@roughsmootheng.in>
Date:   Fri Oct 23 15:53:51 2026 -0400

    Improve protection checks and guidance for downloadable product file URLs (#70198)

    Site Health only requested the woocommerce_uploads directory, whose empty
    index.html returns a blank 200, so servers that ignore .htaccess were
    reported as protected while files were downloadable. For the Force and
    X-Sendfile methods it now requests a temporary file, compares it with a
    control file in the uploads root, and runs as an asynchronous test with
    no cached result, so reloading Site Health checks again.

    Files added with the product editor's Choose file button are unattached
    attachments, which WordPress lists publicly. Keep them out of REST API
    media results, attachment pages and feeds, oEmbed, comments, and links
    for users who can't upload files or manage WooCommerce.

    Update the NGINX instructions so the rule covers multisite and relocated
    uploads, and explain that it must come before other regex locations.

diff --git a/docs/code-snippets/using_nginx_server_to_protect_your_uploads_directory.md b/docs/code-snippets/using_nginx_server_to_protect_your_uploads_directory.md
index 463540305af..573e17ab2d2 100644
--- a/docs/code-snippets/using_nginx_server_to_protect_your_uploads_directory.md
+++ b/docs/code-snippets/using_nginx_server_to_protect_your_uploads_directory.md
@@ -7,29 +7,40 @@ current wccom url: https://woocommerce.com/document/digital-downloadable-product

 # Using NGINX server to protect your upload directory

-If you using NGINX server for your site along with **X-Accel-Redirect/X-Sendfile** or **Force Downloads** download method, it is necessary that you add this configuration for better security:
-
-```php
-# Protect WooCommerce upload folder from being accessed directly.
-# You may want to change this config if you are using "X-Accel-Redirect/X-Sendfile" or "Force Downloads" method for downloadable products.
-# Place this config towards the end of "server" block in NGINX configuration.
-location ~* /wp-content/uploads/woocommerce_uploads/ {
-    if ( $upstream_http_x_accel_redirect = "" ) {
-        return 403;
-    }
+WooCommerce adds an `.htaccess` file to the `woocommerce_uploads` directory to stop downloadable product files from being fetched directly by URL. NGINX ignores `.htaccess` files, so stores running on NGINX need to add their own rule.
+
+The rule depends on your store's download method. To check it, go to **WooCommerce > Settings > Products > Downloadable products** and look at **File download method**. **Force downloads** is the default.
+
+## X-Accel-Redirect/X-Sendfile or Force Downloads
+
+If your store uses the **X-Accel-Redirect/X-Sendfile** or **Force Downloads** download method, add this to your NGINX `server` block:
+
+```nginx
+# Block direct access to WooCommerce downloadable product files.
+# Customers still get their files through WooCommerce download links.
+location ~* /woocommerce_uploads/ {
     internal;
 }
 ```

-And this the configuration in case you are using **Redirect only** download method:
+Place it **before** any other `location` block that uses a regular expression, such as a rule for static files like `location ~* \.(pdf|zip|jpg)$`. NGINX uses the first regular expression location that matches a request, so a static file rule placed earlier would serve downloadable files directly.
+
+The rule matches the `woocommerce_uploads` directory wherever your uploads are stored, including multisite subsites (for example `/wp-content/uploads/sites/2/woocommerce_uploads/`) and sites that move the `wp-content` or uploads directory.

-```php
-# Protect WooCommerce upload folder from being accessed directly.
-# You may want to change this config if you are using "Redirect Only" method for downloadable products.
-# Place this config towards the end of "server" block in NGINX configuration.
-location ~* /wp-content/uploads/woocommerce_uploads/ {
+If **Allow using redirect mode (insecure) as a last resort** is turned on under the same settings, WooCommerce sends customers straight to the file when the selected method fails. This rule blocks those direct requests, so the fallback download fails instead.
+
+## Redirect only
+
+With the **Redirect only** download method, customers are sent straight to the file, so files can't be blocked. To stop the directory from listing its files, add:
+
+```nginx
+location ~* /woocommerce_uploads/ {
     autoindex off;
 }
 ```

-If you do not know which web server you are using, please reach out to your host along with a link to this support page.
+## Check your configuration
+
+After reloading NGINX, go to **Tools > Site Health** in your WordPress dashboard. The WooCommerce uploads directory check reports whether downloadable product files can be accessed directly from the web.
+
+If you do not know which web server you are using, please reach out to your host along with a link to this page.
diff --git a/plugins/woocommerce/changelog/fix-downloadable-file-protection-site-health b/plugins/woocommerce/changelog/fix-downloadable-file-protection-site-health
new file mode 100644
index 00000000000..b0cba00666d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-downloadable-file-protection-site-health
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show a Site Health warning when downloadable product files can be accessed directly, instead of reporting them as protected.
diff --git a/plugins/woocommerce/changelog/fix-hide-downloadable-file-attachments b/plugins/woocommerce/changelog/fix-hide-downloadable-file-attachments
new file mode 100644
index 00000000000..8eabf663bcf
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-hide-downloadable-file-attachments
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Hide downloadable product files from public media listings and attachment pages.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 4b80f06c88e..5b55b555e6c 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -447,6 +447,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Ucp\UcpDiscovery::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Admin\OrderTaxLookupMigrator::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\ProductDownloads\DownloadableFileAttachments::class )->register();

 		// Classes inheriting from RestApiControllerBase.
 		$container->get( Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingRestController::class )->register();
diff --git a/plugins/woocommerce/src/Internal/Admin/SiteHealth.php b/plugins/woocommerce/src/Internal/Admin/SiteHealth.php
index 072eb6ff9ef..ad6e10a4c7b 100644
--- a/plugins/woocommerce/src/Internal/Admin/SiteHealth.php
+++ b/plugins/woocommerce/src/Internal/Admin/SiteHealth.php
@@ -11,8 +11,10 @@ use Automattic\WooCommerce\Enums\DefaultCustomerAddress;
 use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
 use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
+use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
 use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
 use Automattic\WooCommerce\Utilities\OrderUtil;
+use Exception;
 use WC_Admin_Notices;
 use WC_Admin_Status;
 use WC_Helper_Updater;
@@ -26,6 +28,11 @@ defined( 'ABSPATH' ) || exit;
  * SiteHealth class.
  */
 class SiteHealth {
+	/**
+	 * REST API namespace for tests that Site Health runs asynchronously.
+	 */
+	private const REST_NAMESPACE = 'wc-site-health/v1';
+
 	/**
 	 * Class instance.
 	 *
@@ -49,6 +56,7 @@ class SiteHealth {
 	public function __construct() {
 		add_filter( 'site_status_should_suggest_persistent_object_cache', array( $this, 'should_suggest_persistent_object_cache' ) );
 		add_filter( 'site_status_tests', array( $this, 'handle_site_status_tests' ) );
+		add_action( 'rest_api_init', array( $this, 'register_async_test_routes' ) );
 	}

 	/**
@@ -61,20 +69,78 @@ class SiteHealth {
 	 */
 	public function handle_site_status_tests( $tests ) {
 		$tests['direct'] = $tests['direct'] ?? array();
+		$tests['async']  = $tests['async'] ?? array();

 		foreach ( $this->get_woocommerce_site_health_tests() as $test_id => $test ) {
-			$tests['direct'][ $test_id ] = array(
-				'label'     => $test['label'],
-				'test'      => function () use ( $test_id ) {
-					return $this->run_test( $test_id );
-				},
-				'skip_cron' => true,
+			$run = function () use ( $test_id ) {
+				return $this->run_test( $test_id );
+			};
+
+			if ( empty( $test['async'] ) ) {
+				$tests['direct'][ $test_id ] = array(
+					'label'     => $test['label'],
+					'test'      => $run,
+					'skip_cron' => true,
+				);
+				continue;
+			}
+
+			$tests['async'][ $test_id ] = array(
+				'label'             => $test['label'],
+				'test'              => rest_url( self::REST_NAMESPACE . '/tests/' . $this->get_async_test_route( $test_id ) ),
+				'has_rest'          => true,
+				'async_direct_test' => $run,
+				'skip_cron'         => true,
 			);
 		}

 		return $tests;
 	}

+	/**
+	 * Register REST API routes for the tests that Site Health runs asynchronously.
+	 *
+	 * @internal
+	 */
+	public function register_async_test_routes(): void {
+		foreach ( $this->get_woocommerce_site_health_tests() as $test_id => $test ) {
+			if ( empty( $test['async'] ) ) {
+				continue;
+			}
+
+			register_rest_route(
+				self::REST_NAMESPACE,
+				'/tests/' . $this->get_async_test_route( $test_id ),
+				array(
+					'methods'             => 'GET',
+					'callback'            => function () use ( $test_id ) {
+						/**
+						 * This filter is documented in wp-admin/includes/class-wp-site-health.php
+						 *
+						 * Core runs it for direct tests only, so apply it here for tests moved to async.
+						 *
+						 * @since 5.3.0
+						 */
+						return rest_ensure_response( apply_filters( 'site_status_test_result', $this->run_test( $test_id ) ) );
+					},
+					'permission_callback' => function () {
+						return current_user_can( 'view_site_health_checks' );
+					},
+				)
+			);
+		}
+	}
+
+	/**
+	 * Get the REST API route segment for an asynchronous test.
+	 *
+	 * @param string $test_id Site Health test ID.
+	 * @return string
+	 */
+	private function get_async_test_route( string $test_id ): string {
+		return str_replace( '_', '-', $test_id );
+	}
+
 	/**
 	 * Run a registered WooCommerce Site Health test by ID.
 	 *
@@ -138,6 +204,7 @@ class SiteHealth {
 	 * Each entry describes one test:
 	 *   - label: visible test name in Site Health.
 	 *   - badge: 'security' or 'performance'.
+	 *   - async: optional; true to run the test after the page loads, for checks that make loopback requests.
 	 *   - 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).
@@ -171,24 +238,39 @@ class SiteHealth {
 			'woocommerce_uploads_directory_protection' => array(
 				'label' => __( 'WooCommerce uploads directory protection', 'woocommerce' ),
 				'badge' => 'security',
+				'async' => true,
 				'check' => array( $this, 'is_uploads_directory_protected' ),
 				'good'  => array(
 					'label'       => __( 'WooCommerce uploads directory is protected', 'woocommerce' ),
-					'description' => __( 'The directory used for downloadable product files is not browsable from the web.', 'woocommerce' ),
+					'description' => function () {
+						return $this->uses_redirect_download_method()
+							? __( 'The directory used for downloadable product files is not browsable from the web.', 'woocommerce' )
+							: __( 'Downloadable product files cannot be accessed directly from the web.', 'woocommerce' );
+					},
 				),
 				'fail'  => array(
 					'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' );
+						if ( ! empty( $context['unverified'] ) ) {
+							return __( 'WooCommerce could not verify uploads directory protection', 'woocommerce' );
+						}
+
+						return $this->uses_redirect_download_method()
+							? __( 'WooCommerce uploads directory is browsable from the web', 'woocommerce' )
+							: __( 'Downloadable product files can be accessed directly from the web', 'woocommerce' );
 					},
 					'description' => function ( ?array $context ) {
+						if ( $this->uses_redirect_download_method() ) {
+							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' );
+						}
+
 						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' );
+							? __( 'WooCommerce could not confirm whether downloadable product files can be accessed directly from the web. This happens when test files cannot be written to the uploads directory, your site cannot make requests to itself, or uploads are served from somewhere else, such as a CDN or behind a login.', 'woocommerce' )
+							: __( 'Anyone with the URL of a downloadable product file can download it without buying the product. Configure your web server to block direct access to the WooCommerce uploads directory.', 'woocommerce' );
 					},
 					'actions'     => array(
 						array(
@@ -544,22 +626,29 @@ class SiteHealth {
 	}

 	/**
-	 * Check if the WooCommerce uploads directory is protected from directory browsing.
+	 * Check if the WooCommerce uploads directory is protected.
+	 *
+	 * With the "Redirect only" download method files are served directly by design, so only directory browsing is checked.
+	 * Other methods need the files themselves to be blocked.
 	 *
 	 * @return bool|array{unverified: true}
 	 */
 	private function is_uploads_directory_protected() {
-		$cache_key = '_woocommerce_upload_directory_status';
-		$status    = get_transient( $cache_key );
+		$status = $this->uses_redirect_download_method() ? $this->get_uploads_directory_browsing_status() : $this->get_uploads_file_access_status();

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

+		return 'protected' === $status;
+	}
+
+	/**
+	 * Check whether the WooCommerce uploads directory lists its files when requested from the web.
+	 *
+	 * @return string 'protected', 'unprotected', or 'unverified'.
+	 */
+	private function get_uploads_directory_browsing_status(): string {
 		$uploads  = wp_get_upload_dir();
 		$response = wp_safe_remote_get(
 			esc_url_raw( $uploads['baseurl'] . '/woocommerce_uploads/' ),
@@ -569,23 +658,88 @@ class SiteHealth {
 		);

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

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

 		if ( 0 === $response_code ) {
-			set_transient( $cache_key, 'unverified', HOUR_IN_SECONDS );
-			return array( 'unverified' => true );
+			return 'unverified';
+		}
+
+		return ( 200 !== $response_code || empty( wp_remote_retrieve_body( $response ) ) ) ? 'protected' : 'unprotected';
+	}
+
+	/**
+	 * Check whether the web server serves files from the WooCommerce uploads directory directly.
+	 *
+	 * The directory request alone can't tell: the empty index.html WooCommerce installs there returns a blank 200
+	 * even when every file is downloadable. So this requests a temporary file instead.
+	 *
+	 * @return string 'protected', 'unprotected', or 'unverified'.
+	 */
+	private function get_uploads_file_access_status(): string {
+		$is_served = $this->is_uploads_test_file_served( 'woocommerce_uploads/' );
+
+		if ( null === $is_served ) {
+			return 'unverified';
 		}

-		$response_content = wp_remote_retrieve_body( $response );
-		$is_protected     = ( 200 === $response_code && empty( $response_content ) ) || ( 200 !== $response_code );
+		if ( $is_served ) {
+			return 'unprotected';
+		}

-		set_transient( $cache_key, $is_protected ? 'protected' : 'unprotected', DAY_IN_SECONDS );
+		// A blocked file only shows protection if an ordinary upload is served. Offloaded uploads, a site-wide
+		// login, or a server rule for the file type would block both.
+		return $this->is_uploads_test_file_served( '' ) ? 'protected' : 'unverified';
+	}

-		return $is_protected;
+	/**
+	 * Write a temporary file to the uploads directory, request it from the web, and delete it.
+	 *
+	 * @param string $subdir Directory relative to the uploads base directory, with a trailing slash, or ''.
+	 * @return bool|null Whether the file's contents were served, or null if the check could not run.
+	 */
+	private function is_uploads_test_file_served( string $subdir ): ?bool {
+		try {
+			$file_name  = 'wc-site-health-' . bin2hex( random_bytes( 6 ) ) . '.txt';
+			$contents   = bin2hex( random_bytes( 16 ) );
+			$filesystem = FilesystemUtil::get_wp_filesystem_direct();
+		} catch ( Exception $e ) {
+			return null;
+		}
+
+		$uploads   = wp_get_upload_dir();
+		$file_path = $uploads['basedir'] . '/' . $subdir . $file_name;
+
+		if ( ! $filesystem->put_contents( $file_path, $contents ) ) {
+			return null;
+		}
+
+		$response = wp_safe_remote_get( esc_url_raw( $uploads['baseurl'] . '/' . $subdir . $file_name ) );
+		$filesystem->delete( $file_path );
+
+		if ( is_wp_error( $response ) ) {
+			return null;
+		}
+
+		$response_code = intval( wp_remote_retrieve_response_code( $response ) );
+
+		if ( 0 === $response_code ) {
+			return null;
+		}
+
+		// Comparing contents means a login page or soft 404 reached through a redirect doesn't count as served.
+		return 200 === $response_code && trim( wp_remote_retrieve_body( $response ) ) === $contents;
+	}
+
+	/**
+	 * Check whether downloadable products use the "Redirect only" download method.
+	 *
+	 * @return bool
+	 */
+	private function uses_redirect_download_method(): bool {
+		return 'redirect' === get_option( 'woocommerce_file_download_method', 'force' );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/ProductDownloads/DownloadableFileAttachments.php b/plugins/woocommerce/src/Internal/ProductDownloads/DownloadableFileAttachments.php
new file mode 100644
index 00000000000..dd23cb2257d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductDownloads/DownloadableFileAttachments.php
@@ -0,0 +1,276 @@
+<?php
+/**
+ * DownloadableFileAttachments class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ProductDownloads;
+
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use WP_Error;
+use WP_Post;
+use WP_Query;
+use WP_REST_Attachments_Controller;
+
+/**
+ * Keeps downloadable product file URLs out of public attachment views.
+ *
+ * Files added with the product editor's "Choose file" button become unattached attachments in the woocommerce_uploads
+ * directory, and WordPress treats unattached attachments as public. Without this, anyone could find their URLs through
+ * the REST API media endpoint, attachment pages and feeds, or oEmbed. Users who can upload files or manage WooCommerce
+ * still see them, since the media library already lists these files for them.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 11.4.0
+ */
+class DownloadableFileAttachments implements RegisterHooksInterface {
+
+	/**
+	 * Query var that marks a REST API media query as needing downloadable files excluded.
+	 */
+	private const EXCLUDE_QUERY_VAR = 'wc_exclude_downloadable_files';
+
+	/**
+	 * Start of the `_wp_attached_file` path, relative to the uploads directory, for downloadable product files.
+	 */
+	private const DIRECTORY = 'woocommerce_uploads/';
+
+	/**
+	 * Register hooks and filters.
+	 */
+	public function register(): void {
+		add_filter( 'rest_attachment_query', array( $this, 'handle_rest_attachment_query' ) );
+		add_filter( 'posts_where', array( $this, 'handle_posts_where' ), PHP_INT_MAX, 2 );
+		add_filter( 'rest_request_before_callbacks', array( $this, 'handle_rest_request_before_callbacks' ), 10, 3 );
+		add_filter( 'posts_results', array( $this, 'handle_posts_results' ), 10, 2 );
+		add_filter( 'oembed_response_data', array( $this, 'handle_oembed_response_data' ), 10, 2 );
+		add_filter( 'attachment_link', array( $this, 'handle_attachment_link' ), 10, 2 );
+		add_filter( 'get_the_guid', array( $this, 'handle_get_the_guid' ), 10, 2 );
+		add_filter( 'comments_open', array( $this, 'handle_comments_open' ), 10, 2 );
+		add_filter( 'pings_open', array( $this, 'handle_comments_open' ), 10, 2 );
+		add_filter( 'redirect_canonical', array( $this, 'handle_redirect_canonical' ) );
+		add_filter( 'previous_image_link', array( $this, 'handle_adjacent_image_link' ), 10, 2 );
+		add_filter( 'next_image_link', array( $this, 'handle_adjacent_image_link' ), 10, 2 );
+	}
+
+	/**
+	 * Mark REST API media collection queries to exclude downloadable product files.
+	 *
+	 * @internal
+	 *
+	 * @param array|mixed $args WP_Query arguments.
+	 * @return array|mixed
+	 */
+	public function handle_rest_attachment_query( $args ) {
+		if ( is_array( $args ) && ! $this->current_user_can_see_files() ) {
+			$args[ self::EXCLUDE_QUERY_VAR ] = true;
+		}
+
+		return $args;
+	}
+
+	/**
+	 * Exclude attachments stored in the woocommerce_uploads directory from marked queries.
+	 *
+	 * @internal
+	 *
+	 * @param string|mixed   $where WHERE clause of the query.
+	 * @param WP_Query|mixed $query The query.
+	 * @return string|mixed
+	 */
+	public function handle_posts_where( $where, $query ) {
+		if ( ! is_string( $where ) || ! $query instanceof WP_Query || ! $query->get( self::EXCLUDE_QUERY_VAR ) ) {
+			return $where;
+		}
+
+		global $wpdb;
+
+		// Grouping the existing conditions keeps an OR added by another posts_where filter from escaping the exclusion.
+		return " AND ( 1=1 {$where} )" . $wpdb->prepare(
+			" AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} AS wc_attached_file WHERE wc_attached_file.post_id = {$wpdb->posts}.ID AND wc_attached_file.meta_key = '_wp_attached_file' AND wc_attached_file.meta_value LIKE %s )",
+			$wpdb->esc_like( self::DIRECTORY ) . '%'
+		);
+	}
+
+	/**
+	 * Respond with "not found" when a single downloadable product file is requested from the REST API media endpoint.
+	 *
+	 * @internal
+	 *
+	 * @param mixed                                  $response Response to replace the requested version with, or null.
+	 * @param array|mixed                            $handler  Route handler used for the request.
+	 * @param \WP_REST_Request<array<string, mixed>> $request  Request used to generate the response.
+	 * @return mixed
+	 */
+	public function handle_rest_request_before_callbacks( $response, $handler, $request ) {
+		$callback = is_array( $handler ) ? ( $handler['callback'] ?? null ) : null;
+
+		if (
+			is_wp_error( $response )
+			|| ! is_array( $callback )
+			|| ! ( $callback[0] ?? null ) instanceof WP_REST_Attachments_Controller
+			|| 'get_item' !== ( $callback[1] ?? null )
+			|| ! $this->is_hidden_attachment( absint( $request['id'] ?? 0 ) )
+		) {
+			return $response;
+		}
+
+		return new WP_Error( 'rest_post_invalid_id', __( 'Invalid post ID.', 'woocommerce' ), array( 'status' => 404 ) );
+	}
+
+	/**
+	 * Drop a downloadable product file from the main query when it is requested as a single post.
+	 *
+	 * With nothing found, WordPress shows a 404 instead of the attachment page or its redirect to the file, and feeds,
+	 * embeds, and theme templates have no attachment to render.
+	 *
+	 * @internal
+	 *
+	 * @param WP_Post[]|mixed $posts Posts found by the query.
+	 * @param WP_Query|mixed  $query The query.
+	 * @return WP_Post[]|mixed
+	 */
+	public function handle_posts_results( $posts, $query ) {
+		if ( ! is_array( $posts ) || 1 !== count( $posts ) || ! $query instanceof WP_Query || ! $query->is_main_query() || ! $query->is_singular() ) {
+			return $posts;
+		}
+
+		$post = reset( $posts );
+
+		if ( ! $post instanceof WP_Post || 'attachment' !== $post->post_type || ! $this->is_hidden_attachment( $post->ID ) ) {
+			return $posts;
+		}
+
+		// Pretty permalinks set the queried object before posts are fetched, and feed titles print its title.
+		unset( $query->queried_object, $query->queried_object_id );
+
+		return array();
+	}
+
+	/**
+	 * Skip canonical redirects for downloadable product file attachment requests.
+	 *
+	 * Feed requests aren't turned into 404s, so with attachment pages disabled WordPress would redirect them to the file.
+	 *
+	 * @internal
+	 *
+	 * @param string|false|mixed $redirect_url Redirect URL.
+	 * @return string|false|mixed
+	 */
+	public function handle_redirect_canonical( $redirect_url ) {
+		return ( is_attachment() && $this->is_hidden_attachment( absint( get_query_var( 'attachment_id' ) ) ) ) ? false : $redirect_url;
+	}
+
+	/**
+	 * Leave out previous and next image links that point to downloadable product files.
+	 *
+	 * Attachment pages for unattached images link to other unattached images, and the link can show the image itself.
+	 *
+	 * @internal
+	 *
+	 * @param string|mixed $output        Link HTML.
+	 * @param int|mixed    $attachment_id ID of the linked attachment.
+	 * @return string|mixed
+	 */
+	public function handle_adjacent_image_link( $output, $attachment_id ) {
+		return $this->is_hidden_attachment( absint( $attachment_id ) ) ? '' : $output;
+	}
+
+	/**
+	 * Use an ID-only link for downloadable product files.
+	 *
+	 * The usual link includes the attachment's slug, which is based on the original file name. Canonical and other
+	 * redirects that point to the attachment then land on a URL that reveals nothing and shows a 404.
+	 *
+	 * @internal
+	 *
+	 * @param string|mixed $link          Attachment link.
+	 * @param int|mixed    $attachment_id Attachment ID.
+	 * @return string|mixed
+	 */
+	public function handle_attachment_link( $link, $attachment_id ) {
+		$attachment_id = absint( $attachment_id );
+
+		return $this->is_hidden_attachment( $attachment_id ) ? add_query_arg( 'attachment_id', $attachment_id, home_url( '/' ) ) : $link;
+	}
+
+	/**
+	 * Use the ID-only link as the GUID of downloadable product files, which is otherwise the file's URL.
+	 *
+	 * Comment feeds print the GUID of the post each comment belongs to.
+	 *
+	 * @internal
+	 *
+	 * @param string|mixed $guid    Post GUID.
+	 * @param int|mixed    $post_id Post ID.
+	 * @return string|mixed
+	 */
+	public function handle_get_the_guid( $guid, $post_id ) {
+		$post_id = absint( $post_id );
+
+		return $this->is_hidden_attachment( $post_id ) ? add_query_arg( 'attachment_id', $post_id, home_url( '/' ) ) : $guid;
+	}
+
+	/**
+	 * Close comments and pings on downloadable product files, which would otherwise list them in comment feeds.
+	 *
+	 * @internal
+	 *
+	 * @param bool|mixed $open    Whether comments or pings are open.
+	 * @param int|mixed  $post_id Post ID.
+	 * @return bool|mixed
+	 */
+	public function handle_comments_open( $open, $post_id ) {
+		return $this->is_downloadable_file( absint( $post_id ) ) ? false : $open;
+	}
+
+	/**
+	 * Return no oEmbed data for downloadable product files, which would otherwise include the URL of an image file.
+	 *
+	 * @internal
+	 *
+	 * @param array|false|mixed  $data oEmbed response data.
+	 * @param WP_Post|null|mixed $post Post the data was generated for.
+	 * @return array|false|mixed
+	 */
+	public function handle_oembed_response_data( $data, $post ) {
+		return ( $post instanceof WP_Post && $this->is_hidden_attachment( $post->ID ) ) ? false : $data;
+	}
+
+	/**
+	 * Check whether an attachment is a downloadable product file the current user should not see.
+	 *
+	 * @param int $attachment_id Attachment ID.
+	 * @return bool
+	 */
+	private function is_hidden_attachment( int $attachment_id ): bool {
+		return $this->is_downloadable_file( $attachment_id ) && ! $this->current_user_can_see_files();
+	}
+
+	/**
+	 * Check whether an attachment is stored in the woocommerce_uploads directory.
+	 *
+	 * @param int $attachment_id Attachment ID.
+	 * @return bool
+	 */
+	private function is_downloadable_file( int $attachment_id ): bool {
+		// Checking the post type first skips a meta lookup for the posts that comment and GUID hooks run on.
+		if ( $attachment_id <= 0 || 'attachment' !== get_post_type( $attachment_id ) ) {
+			return false;
+		}
+
+		// Case-insensitive to match the SQL LIKE used for REST API collections.
+		return 0 === stripos( (string) get_post_meta( $attachment_id, '_wp_attached_file', true ), self::DIRECTORY );
+	}
+
+	/**
+	 * Check whether the current user may see downloadable product file URLs.
+	 *
+	 * @return bool
+	 */
+	private function current_user_can_see_files(): bool {
+		return current_user_can( 'upload_files' ) || current_user_can( 'manage_woocommerce' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php
index 9e50d59fa7d..fd2d9a85be2 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php
@@ -8,6 +8,7 @@ use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Registe
 use WC_Admin_Notices;
 use WC_Unit_Test_Case;
 use WP_Error;
+use WP_REST_Request;

 /**
  * Tests for the SiteHealth class.
@@ -26,7 +27,6 @@ class SiteHealthTest extends WC_Unit_Test_Case {
 	public function setUp(): void {
 		parent::setUp();
 		$this->sut = new SiteHealth();
-		delete_transient( '_woocommerce_upload_directory_status' );
 		delete_option( 'wc_downloads_approved_directories_mode' );
 		WC_Admin_Notices::remove_notice( 'download_directories_sync_complete', true );
 	}
@@ -35,8 +35,8 @@ class SiteHealthTest extends WC_Unit_Test_Case {
 	 * Tear down test fixtures.
 	 */
 	public function tearDown(): void {
-		delete_transient( '_woocommerce_upload_directory_status' );
 		delete_option( 'wc_downloads_approved_directories_mode' );
+		delete_option( 'woocommerce_file_download_method' );
 		WC_Admin_Notices::remove_notice( 'download_directories_sync_complete', true );
 		parent::tearDown();
 	}
@@ -112,11 +112,10 @@ class SiteHealthTest extends WC_Unit_Test_Case {

 			$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.' );
+			$this->assertSame( 2, $request_count, 'Each run should check again instead of reusing an earlier result.' );
 		} finally {
 			remove_filter( 'pre_http_request', $filter_callback, 10 );
 		}
@@ -126,12 +125,9 @@ class SiteHealthTest extends WC_Unit_Test_Case {
 	 * @testdox Upload directory protection check is inconclusive when the HTTP response code is zero.
 	 */
 	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 ) {
+		$filter_callback = static function ( $_preempt, $_parsed_args, $_url ) {
 			unset( $_preempt, $_parsed_args, $_url );

-			++$request_count;
-
 			return array(
 				'headers'  => array(),
 				'body'     => '',
@@ -151,20 +147,17 @@ class SiteHealthTest extends WC_Unit_Test_Case {

 			$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.
+	 * @testdox Upload directory protection check is critical when directory browsing is exposed with the redirect download method.
 	 */
 	public function test_uploads_directory_protection_is_critical_when_directory_browsing_is_exposed(): void {
+		update_option( 'woocommerce_file_download_method', 'redirect' );
+
 		$filter_callback = static function ( $_preempt, $_parsed_args, $_url ) {
 			unset( $_preempt, $_parsed_args, $_url );

@@ -187,9 +180,200 @@ class SiteHealthTest extends WC_Unit_Test_Case {

 			$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 );
 		}
 	}
+
+	/**
+	 * @testdox Upload directory protection check is critical when the web server serves uploaded files directly.
+	 */
+	public function test_uploads_directory_protection_is_critical_when_files_are_served_directly(): void {
+		$uploads         = wp_get_upload_dir();
+		$requested_paths = array();
+		$filter_callback = static function ( $_preempt, $_parsed_args, $url ) use ( $uploads, &$requested_paths ) {
+			unset( $_preempt, $_parsed_args );
+
+			$path              = str_replace( $uploads['baseurl'], $uploads['basedir'], $url );
+			$requested_paths[] = $path;
+
+			return array(
+				'headers'  => array(),
+				'body'     => file_get_contents( $path ), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+				'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'], 'Directly downloadable files should be a confirmed security failure.' );
+			$this->assertSame( 'Downloadable product files can be accessed directly from the web', $result['label'], 'Directly downloadable files should report that files are exposed.' );
+			$this->assertStringStartsWith( $uploads['basedir'] . '/woocommerce_uploads/', $requested_paths[0], 'The check should request a file in the WooCommerce uploads directory.' );
+			$this->assertFileDoesNotExist( $requested_paths[0], 'The temporary file should be deleted after the request.' );
+		} finally {
+			remove_filter( 'pre_http_request', $filter_callback, 10 );
+		}
+	}
+
+	/**
+	 * @testdox Upload directory protection check is good when only files in the WooCommerce uploads directory are blocked.
+	 * @testWith [403, "Forbidden"]
+	 *           [200, "<html><body>Log in</body></html>"]
+	 *
+	 * @param int    $response_code Response code returned for the file in the WooCommerce uploads directory.
+	 * @param string $response_body Response body returned for the file in the WooCommerce uploads directory.
+	 */
+	public function test_uploads_directory_protection_is_good_when_files_are_not_served( int $response_code, string $response_body ): void {
+		$uploads         = wp_get_upload_dir();
+		$filter_callback = static function ( $_preempt, $_parsed_args, $url ) use ( $uploads, $response_code, $response_body ) {
+			unset( $_preempt, $_parsed_args );
+
+			$is_blocked = false !== strpos( $url, '/woocommerce_uploads/' );
+
+			return array(
+				'headers'  => array(),
+				'body'     => $is_blocked ? $response_body : file_get_contents( str_replace( $uploads['baseurl'], $uploads['basedir'], $url ) ), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+				'response' => array(
+					'code'    => $is_blocked ? $response_code : 200,
+					'message' => '',
+				),
+				'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( 'good', $result['status'], 'Files that are not served should be reported as protected.' );
+			$this->assertStringContainsString( 'Downloadable product files cannot be accessed directly from the web.', $result['description'], 'The protected description should refer to file access.' );
+		} finally {
+			remove_filter( 'pre_http_request', $filter_callback, 10 );
+		}
+	}
+
+	/**
+	 * @testdox Upload directory protection check is inconclusive when no uploaded file is served.
+	 */
+	public function test_uploads_directory_protection_is_inconclusive_when_no_uploads_are_served(): void {
+		$uploads         = wp_get_upload_dir();
+		$requested_urls  = array();
+		$filter_callback = static function ( $_preempt, $_parsed_args, $url ) use ( &$requested_urls ) {
+			unset( $_preempt, $_parsed_args );
+
+			$requested_urls[] = $url;
+
+			return array(
+				'headers'  => array(),
+				'body'     => 'Unauthorized',
+				'response' => array(
+					'code'    => 401,
+					'message' => 'Unauthorized',
+				),
+				'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( 'recommended', $result['status'], 'A site that blocks every upload should not be reported as protected.' );
+			foreach ( $requested_urls as $url ) {
+				$this->assertFileDoesNotExist( str_replace( $uploads['baseurl'], $uploads['basedir'], $url ), 'Each temporary file should be deleted after its request.' );
+			}
+		} finally {
+			remove_filter( 'pre_http_request', $filter_callback, 10 );
+		}
+	}
+
+	/**
+	 * @testdox Upload directory protection check is registered to run asynchronously through the REST API.
+	 */
+	public function test_uploads_directory_protection_is_registered_as_async_test(): void {
+		$tests = $this->sut->handle_site_status_tests( array() );
+		$test  = $tests['async']['woocommerce_uploads_directory_protection'] ?? array();
+
+		$this->assertArrayNotHasKey( 'woocommerce_uploads_directory_protection', $tests['direct'], 'The check should not run while the page renders.' );
+		$this->assertTrue( $test['has_rest'] ?? false, 'Site Health should run the check through the REST API.' );
+		$this->assertSame( rest_url( 'wc-site-health/v1/tests/woocommerce-uploads-directory-protection' ), $test['test'] ?? null, 'The test should point to its REST API route.' );
+		$this->assertTrue( $test['skip_cron'] ?? false, 'The scheduled Site Health check should not write test files.' );
+	}
+
+	/**
+	 * @testdox Upload directory protection REST API route returns the result only to users who can view Site Health checks.
+	 */
+	public function test_uploads_directory_protection_rest_route_requires_site_health_access(): void {
+		$filter_callback = static function () {
+			return new WP_Error( 'http_request_failed', 'Request failed.' );
+		};
+		add_filter( 'pre_http_request', $filter_callback );
+		$this->create_rest_server_with_routes( array( array( $this->sut, 'register_async_test_routes' ) ) );
+
+		try {
+			$request = new WP_REST_Request( 'GET', '/wc-site-health/v1/tests/woocommerce-uploads-directory-protection' );
+
+			wp_set_current_user( 0 );
+			$this->assertSame( 401, rest_do_request( $request )->get_status(), 'Visitors should not be able to run the check.' );
+
+			wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+			$response = rest_do_request( $request );
+			$this->assertSame( 200, $response->get_status(), 'Users who can view Site Health checks should get the result.' );
+			$this->assertSame( 'woocommerce_uploads_directory_protection', $response->get_data()['test'] ?? null, 'The response should be the Site Health result.' );
+
+			$result_filter = static function ( $result ) {
+				$result['label'] = 'Filtered label';
+				return $result;
+			};
+			add_filter( 'site_status_test_result', $result_filter );
+			$this->assertSame( 'Filtered label', rest_do_request( $request )->get_data()['label'] ?? null, 'The site_status_test_result filter should still apply.' );
+		} finally {
+			remove_filter( 'pre_http_request', $filter_callback );
+			remove_filter( 'site_status_test_result', $result_filter ?? '__return_null' );
+			$this->clear_rest_server();
+		}
+	}
+
+	/**
+	 * @testdox Upload directory protection check is inconclusive when the temporary file cannot be written.
+	 */
+	public function test_uploads_directory_protection_is_inconclusive_when_file_cannot_be_written(): void {
+		$request_count       = 0;
+		$http_callback       = static function () use ( &$request_count ) {
+			++$request_count;
+
+			return new WP_Error( 'http_request_failed', 'Unexpected request.' );
+		};
+		$upload_dir_callback = static function ( $upload_dir ) {
+			$upload_dir['basedir'] .= '/wc-missing-directory';
+
+			return $upload_dir;
+		};
+
+		add_filter( 'pre_http_request', $http_callback );
+		add_filter( 'upload_dir', $upload_dir_callback );
+
+		try {
+			$result = $this->sut->run_test( 'woocommerce_uploads_directory_protection' );
+
+			$this->assertSame( 'recommended', $result['status'], 'A file that cannot be written should not be reported as a confirmed security failure.' );
+			$this->assertSame( 'WooCommerce could not verify uploads directory protection', $result['label'], 'A file that cannot be written should report that the result could not be verified.' );
+			$this->assertSame( 0, $request_count, 'No request should be made without a temporary file.' );
+		} finally {
+			remove_filter( 'pre_http_request', $http_callback );
+			remove_filter( 'upload_dir', $upload_dir_callback );
+		}
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductDownloads/DownloadableFileAttachmentsTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductDownloads/DownloadableFileAttachmentsTest.php
new file mode 100644
index 00000000000..82340ec4449
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductDownloads/DownloadableFileAttachmentsTest.php
@@ -0,0 +1,291 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductDownloads;
+
+use WC_Unit_Test_Case;
+use WP_Query;
+use WP_REST_Request;
+
+/**
+ * Tests for the hooks registered by the DownloadableFileAttachments class.
+ */
+class DownloadableFileAttachmentsTest extends WC_Unit_Test_Case {
+	/**
+	 * Attachment stored in the woocommerce_uploads directory.
+	 *
+	 * @var int
+	 */
+	private int $downloadable_file_id;
+
+	/**
+	 * Attachment stored in the regular uploads directory, in a folder whose name contains "woocommerce_uploads".
+	 *
+	 * @var int
+	 */
+	private int $image_id;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->downloadable_file_id = self::factory()->attachment->create_object(
+			array(
+				'file'           => 'woocommerce_uploads/2026/10/ebook-abc123.pdf',
+				'post_mime_type' => 'application/pdf',
+			)
+		);
+		$this->image_id             = self::factory()->attachment->create_object(
+			array(
+				'file'           => '2026/10/my_woocommerce_uploads/photo.jpg',
+				'post_mime_type' => 'image/jpeg',
+				'comment_status' => 'open',
+			)
+		);
+	}
+
+	/**
+	 * @testdox REST API media collections only include downloadable product files for users who can upload files.
+	 * @testWith ["", false]
+	 *           ["customer", false]
+	 *           ["author", true]
+	 *
+	 * @param string $role                 Role of the current user, or empty for a visitor.
+	 * @param bool   $expect_download_file Whether the downloadable product file should be listed.
+	 */
+	public function test_rest_media_collection_visibility( string $role, bool $expect_download_file ): void {
+		wp_set_current_user( $role ? self::factory()->user->create( array( 'role' => $role ) ) : 0 );
+
+		$response = rest_do_request( new WP_REST_Request( 'GET', '/wp/v2/media' ) );
+		$ids      = wp_list_pluck( $response->get_data(), 'id' );
+
+		$this->assertSame( 200, $response->get_status(), 'The media collection should load.' );
+		$this->assertContains( $this->image_id, $ids, 'Regular attachments should stay listed.' );
+		$this->assertSame( $expect_download_file, in_array( $this->downloadable_file_id, $ids, true ), 'Downloadable product files should only be listed for users who can upload files.' );
+	}
+
+	/**
+	 * @testdox REST API media collections stay filtered when another filter adds an OR condition.
+	 */
+	public function test_rest_media_collection_exclusion_survives_or_conditions(): void {
+		global $wpdb;
+
+		$or_filter = static function ( $where ) use ( $wpdb ) {
+			return $where . " OR {$wpdb->posts}.post_title = 'never matches'";
+		};
+		add_filter( 'posts_where', $or_filter, 5 );
+		wp_set_current_user( 0 );
+
+		$ids = wp_list_pluck( rest_do_request( new WP_REST_Request( 'GET', '/wp/v2/media' ) )->get_data(), 'id' );
+
+		$this->assertNotContains( $this->downloadable_file_id, $ids, 'An OR added by another filter should not bring back downloadable product files.' );
+		$this->assertContains( $this->image_id, $ids, 'Regular attachments should stay listed.' );
+	}
+
+	/**
+	 * @testdox REST API media items are not found for downloadable product files unless the user can upload files.
+	 * @testWith ["", 404]
+	 *           ["customer", 404]
+	 *           ["author", 200]
+	 *
+	 * @param string $role            Role of the current user, or empty for a visitor.
+	 * @param int    $expected_status Expected response status for the downloadable product file.
+	 */
+	public function test_rest_media_item_visibility( string $role, int $expected_status ): void {
+		wp_set_current_user( $role ? self::factory()->user->create( array( 'role' => $role ) ) : 0 );
+
+		$download_response = rest_do_request( new WP_REST_Request( 'GET', '/wp/v2/media/' . $this->downloadable_file_id ) );
+		$image_response    = rest_do_request( new WP_REST_Request( 'GET', '/wp/v2/media/' . $this->image_id ) );
+
+		$this->assertSame( $expected_status, $download_response->get_status(), 'Downloadable product files should only be returned to users who can upload files.' );
+		$this->assertSame( 200, $image_response->get_status(), 'Regular attachments should stay readable.' );
+	}
+
+	/**
+	 * @testdox oEmbed responses are not found for downloadable product image files requested by visitors.
+	 */
+	public function test_oembed_is_not_found_for_downloadable_files(): void {
+		$downloadable_image_id = self::factory()->attachment->create_object(
+			array(
+				'file'           => 'woocommerce_uploads/2026/10/artwork-abc123.jpg',
+				'post_mime_type' => 'image/jpeg',
+			)
+		);
+		wp_set_current_user( 0 );
+
+		$responses = array();
+		foreach ( array( $downloadable_image_id, $this->image_id ) as $attachment_id ) {
+			$request = new WP_REST_Request( 'GET', '/oembed/1.0/embed' );
+			$request->set_param( 'url', add_query_arg( 'attachment_id', $attachment_id, home_url( '/' ) ) );
+			$responses[ $attachment_id ] = rest_do_request( $request );
+		}
+
+		$this->assertSame( 404, $responses[ $downloadable_image_id ]->get_status(), 'Visitors should not get oEmbed data for downloadable product files.' );
+		$this->assertSame( 200, $responses[ $this->image_id ]->get_status(), 'Regular attachments should keep their oEmbed data.' );
+	}
+
+	/**
+	 * @testdox Attachment pages for downloadable product files show a 404 to visitors.
+	 */
+	public function test_attachment_page_is_not_found_for_visitors(): void {
+		update_option( 'wp_attachment_pages_enabled', 0 );
+		wp_set_current_user( 0 );
+		$url = add_query_arg( 'attachment_id', $this->downloadable_file_id, home_url( '/' ) );
+		$this->go_to( $url );
+
+		$this->assertTrue( is_404(), 'Visitors should get a 404 instead of the attachment page or file redirect.' );
+		$this->assertFalse( have_posts(), 'The attachment should not be available to templates.' );
+		$this->assertStringNotContainsString( 'woocommerce_uploads', (string) redirect_canonical( $url, false ), 'The request should not redirect to the file.' );
+	}
+
+	/**
+	 * @testdox Feeds for downloadable product files have no posts for visitors.
+	 */
+	public function test_attachment_feed_is_empty_for_visitors(): void {
+		update_option( 'wp_attachment_pages_enabled', 0 );
+		wp_set_current_user( 0 );
+		$feed_url = add_query_arg(
+			array(
+				'attachment_id'   => $this->downloadable_file_id,
+				'feed'            => 'rss2',
+				'withoutcomments' => 1,
+			),
+			home_url( '/' )
+		);
+		$this->go_to( $feed_url );
+
+		$this->assertTrue( is_feed(), 'The request should still be a feed.' );
+		$this->assertFalse( have_posts(), 'The feed should have no attachment to render.' );
+		$this->assertStringNotContainsString( 'woocommerce_uploads', (string) redirect_canonical( $feed_url, false ), 'The feed should not redirect to the file.' );
+	}
+
+	/**
+	 * @testdox Pretty permalink feeds for downloadable product files are empty and don't redirect to the file for visitors.
+	 */
+	public function test_pretty_permalink_feed_is_empty_for_visitors(): void {
+		update_option( 'wp_attachment_pages_enabled', 0 );
+		$this->set_permalink_structure( '/%postname%/' );
+		self::factory()->attachment->create_object(
+			array(
+				'file'           => 'woocommerce_uploads/2026/10/guide-abc123.pdf',
+				'post_mime_type' => 'application/pdf',
+				'post_title'     => 'guide-abc123',
+			)
+		);
+		$feed_url = home_url( '/guide-abc123/feed/' );
+
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'shop_manager' ) ) );
+		$this->go_to( $feed_url );
+		$this->assertTrue( is_attachment(), 'The URL should resolve to the attachment for users who can see it.' );
+
+		wp_set_current_user( 0 );
+		$this->go_to( $feed_url );
+
+		$this->assertFalse( have_posts(), 'The feed should have no attachment to render.' );
+		$this->assertStringNotContainsString( 'guide-abc123', get_wp_title_rss(), 'The feed title should not reveal the file name.' );
+		$this->assertStringNotContainsString( 'woocommerce_uploads', (string) redirect_canonical( $feed_url, false ), 'The feed should not redirect to the file.' );
+	}
+
+	/**
+	 * @testdox Downloadable product files attached to a post show a 404 to visitors when requested by ID.
+	 */
+	public function test_attached_file_requested_by_id_is_not_found_for_visitors(): void {
+		$attachment_id = self::factory()->attachment->create_object(
+			array(
+				'file'           => 'woocommerce_uploads/2026/10/manual-abc123.pdf',
+				'post_mime_type' => 'application/pdf',
+				'post_parent'    => self::factory()->post->create(),
+			)
+		);
+		wp_set_current_user( 0 );
+		$this->go_to(
+			add_query_arg(
+				array(
+					'p'         => $attachment_id,
+					'post_type' => 'attachment',
+				),
+				home_url( '/' )
+			)
+		);
+
+		$this->assertTrue( is_404(), 'Visitors should get a 404 for attached downloadable product files.' );
+	}
+
+	/**
+	 * @testdox Links to downloadable product files leave out the file's slug unless the user can upload files.
+	 */
+	public function test_attachment_link_leaves_out_slug_for_visitors(): void {
+		$this->set_permalink_structure( '/%postname%/' );
+		$attachment_id = self::factory()->attachment->create_object(
+			array(
+				'file'           => 'woocommerce_uploads/2026/10/manual-abc123.pdf',
+				'post_mime_type' => 'application/pdf',
+				'post_title'     => 'manual-abc123',
+				'post_parent'    => self::factory()->post->create(),
+			)
+		);
+		$id_only_link  = add_query_arg( 'attachment_id', $attachment_id, home_url( '/' ) );
+		$requested_url = add_query_arg( 'p', $attachment_id, home_url( '/' ) );
+
+		wp_set_current_user( 0 );
+		// Runs the main query so redirect_canonical() below sees the same state as a real request.
+		$this->go_to( $requested_url );
+
+		$this->assertSame( $id_only_link, get_permalink( $attachment_id ), 'Visitors should get an ID-only link.' );
+		$this->assertStringNotContainsString( 'manual-abc123', (string) redirect_canonical( $requested_url, false ), 'Canonical redirects should not reveal the slug.' );
+
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'author' ) ) );
+		$this->assertStringContainsString( 'manual-abc123', get_permalink( $attachment_id ), 'Users who can upload files should keep the usual link.' );
+	}
+
+	/**
+	 * @testdox Downloadable product files take no comments and keep their file URL out of GUIDs for visitors.
+	 */
+	public function test_comments_and_guid(): void {
+		wp_set_current_user( 0 );
+
+		$this->assertFalse( comments_open( $this->downloadable_file_id ), 'Comments should be closed on downloadable product files.' );
+		$this->assertFalse( pings_open( $this->downloadable_file_id ), 'Pings should be closed on downloadable product files.' );
+		$this->assertSame( add_query_arg( 'attachment_id', $this->downloadable_file_id, home_url( '/' ) ), get_the_guid( $this->downloadable_file_id ), 'Visitors should not get the file URL as the GUID.' );
+		$this->assertTrue( comments_open( $this->image_id ), 'Regular attachments should keep their comment status.' );
+	}
+
+	/**
+	 * @testdox Previous and next image links to downloadable product files are left out for visitors.
+	 * @testWith ["previous_image_link"]
+	 *           ["next_image_link"]
+	 *
+	 * @param string $hook_name Adjacent image link filter.
+	 */
+	public function test_adjacent_image_links_leave_out_downloadable_files( string $hook_name ): void {
+		wp_set_current_user( 0 );
+
+		$this->assertSame( '', apply_filters( $hook_name, '<a href="#">link</a>', $this->downloadable_file_id, 'thumbnail', false ), 'Links to downloadable product files should be removed.' );
+		$this->assertSame( '<a href="#">link</a>', apply_filters( $hook_name, '<a href="#">link</a>', $this->image_id, 'thumbnail', false ), 'Links to regular images should stay.' );
+	}
+
+	/**
+	 * @testdox Queries other than the main query still find downloadable product files.
+	 */
+	public function test_secondary_queries_still_find_downloadable_files(): void {
+		wp_set_current_user( 0 );
+
+		$query = new WP_Query( array( 'attachment_id' => $this->downloadable_file_id ) );
+
+		$this->assertSame( array( $this->downloadable_file_id ), wp_list_pluck( $query->posts, 'ID' ), 'Only the main query should drop the attachment.' );
+	}
+
+	/**
+	 * @testdox Attachment pages stay available for regular attachments and for users who can manage WooCommerce.
+	 */
+	public function test_attachment_page_is_unchanged_when_not_hidden(): void {
+		wp_set_current_user( 0 );
+		$this->go_to( add_query_arg( 'attachment_id', $this->image_id, home_url( '/' ) ) );
+		$this->assertTrue( is_attachment(), 'Regular attachment pages should not change.' );
+
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'shop_manager' ) ) );
+		$this->go_to( add_query_arg( 'attachment_id', $this->downloadable_file_id, home_url( '/' ) ) );
+		$this->assertTrue( is_attachment(), 'Users who can manage WooCommerce should still reach downloadable product file attachments.' );
+	}
+}