Commit ed3fdeeef44 for woocommerce
commit ed3fdeeef44caa75745398d6b3bff8dad4f05781
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date: Wed Jul 15 13:22:45 2026 +0300
Fix download filename for external URLs without a filename in path (#66604)
* Fix force-download filename for external URLs without a filename in path
Downloadable files hosted on external URLs whose path contains no real
filename (for example Google Drive links like /uc?export=download&id=...)
were served under a broken name: the download handler derived the filename
exclusively from basename() of the URL, yielding "uc" with no extension,
while the Force Download method streamed the remote body and discarded the
filename the remote server announced in its Content-Disposition header.
Open the remote stream before sending our own headers and, when the
URL-derived filename has no extension, resolve the real filename from the
response headers collected by the HTTP stream wrapper: the Content-Disposition
filename (RFC 5987 filename* preferred) of the final response in the redirect
chain, falling back to an extension derived from its Content-Type. The
already-open handle is then reused for streaming, so no extra HTTP request is
made. Files whose URL already contains a proper filename are unaffected.
* Add changelog entry for external download filename fix
* Add test pinning quoted filename* parsing behavior
A review note suggested the non-standard quoted form of the RFC 5987
parameter (filename*="UTF-8''name.pdf") emitted by some non-conforming
servers might not be parsed. It is handled — the parser trims quotes from
the captured value — but the behavior was unpinned. Add a characterization
test so it cannot silently regress.
* Use named capture groups in filename regexes and drop stray @since
Address review feedback: the repo's regex guidelines ask for named capture
groups and documented patterns, and private methods in this codebase do not
carry @since annotations. Rename the Content-Disposition matches to
encoded_value/quoted_value/bare_value, document what each pattern matches,
and remove the @since tag from the private readfile_from_handle() helper.
Also add a unit test for the unquoted filename token form (e.g.
filename=file.zip), which the alternation's unmatched-group semantics now
make load-bearing.
* Prepare server config before opening the remote download file
check_server_config() raises the time limit and releases the session, and
used to run before any I/O as the first statement of download_headers().
Opening the remote stream ahead of the headers deferred it until after the
connect, so a hung remote host could exceed wall-clock execution timers and
keep a third-party native session locked for the duration of the connect.
Run it at the top of download_file_force() instead, ahead of the remote
fopen(). The call is idempotent, so the invocation inside
download_headers() (still needed by the X-Sendfile paths) stays.
* Keep WC_CHUNK_SIZE defined when readfile_chunked cannot open the file
Extracting readfile_from_handle() moved the WC_CHUNK_SIZE define() behind a
successful fopen(), so the public readfile_chunked() no longer defined the
constant when the open failed — an observable change on a public method for
external code relying on that side effect. Restore the define ahead of the
fopen(); the guarded define in readfile_from_handle() stays for the
handle-based path.
* Preserve a filter-customized filename when resolving from response headers
A callback on the public woocommerce_file_download_filename filter that
deliberately returns an extensionless name (e.g. the product name) had its
value discarded wholesale by the remote-filename resolution, which replaced
any extensionless filename with the one announced by the remote server. The
missing piece is the extension, not the name.
Detect customization by comparing the incoming filename with the
URL-derived default (extracted into derive_filename_from_file_path(), now
shared with download()). When they differ, keep the customized name and
only append the extension taken from the remote Content-Disposition
filename, falling back to the Content-Type header as before. Unfiltered
names keep the previous behavior of adopting the full remote-announced
filename.
* Scope the Content-Type fallback to remote downloads
The fallback that derives the Content-Type from the resolved filename when
the file path lacks a usable extension ran for every download_headers()
caller, including the X-Sendfile/X-Accel-Redirect paths for local files —
changing the served type for local downloads this fix never intended to
touch. It also trusted the resolved filename unconditionally, although for
remote files its extension can originate from the remote server's own
response headers: a compromised host announcing text/html could get markup
served inline from the store's origin for unfiltered_html users with inline
delivery enabled.
Extract the decision into get_content_type_for_served_download(), apply the
filename fallback only when serving a remote file, and keep
application/force-download whenever the fallback would yield a type
browsers may render or execute inline (HTML, XHTML, SVG, XML).
* Add test covering the error path when a remote file cannot be opened
The remote branch of download_file_force() had no coverage: opening the
stream before sending headers means a failed open must produce a proper
error page rather than an HTML error body served under download headers.
Pin that behavior by asserting a wp_die (WPDieException in the test suite)
for an unopenable remote URL with the redirect fallback disabled. The
successful-serve path ends in exit and needs a live HTTP origin, so it
remains covered by manual end-to-end testing.
diff --git a/plugins/woocommerce/changelog/fix-26879-external-download-filename b/plugins/woocommerce/changelog/fix-26879-external-download-filename
new file mode 100644
index 00000000000..7d60de2818d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-26879-external-download-filename
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Use the filename announced by the remote server (Content-Disposition/Content-Type) when force-downloading an external file whose URL has no filename in its path, such as Google Drive links.
diff --git a/plugins/woocommerce/includes/class-wc-download-handler.php b/plugins/woocommerce/includes/class-wc-download-handler.php
index 2db60ad25cb..0c66bb4d85e 100644
--- a/plugins/woocommerce/includes/class-wc-download-handler.php
+++ b/plugins/woocommerce/includes/class-wc-download-handler.php
@@ -231,13 +231,14 @@ class WC_Download_Handler {
self::download_error( __( 'No file defined', 'woocommerce' ) );
}
- $filename = basename( $file_path );
-
- if ( strstr( $filename, '?' ) ) {
- $filename = current( explode( '?', $filename ) );
- }
-
- $filename = apply_filters( 'woocommerce_file_download_filename', $filename, $product_id );
+ /**
+ * Filter the filename of a downloadable product file before it is served.
+ *
+ * @since 2.2.0
+ * @param string $filename Filename derived from the file URL.
+ * @param integer $product_id Product ID of the product being downloaded.
+ */
+ $filename = apply_filters( 'woocommerce_file_download_filename', self::derive_filename_from_file_path( $file_path ), $product_id );
/**
* Filter download method.
@@ -256,6 +257,22 @@ class WC_Download_Handler {
do_action( 'woocommerce_download_file_' . $file_download_method, $file_path, $filename );
}
+ /**
+ * Derive a download filename from the file path or URL it will be served from.
+ *
+ * @param string $file_path File path or URL.
+ * @return string
+ */
+ private static function derive_filename_from_file_path( $file_path ) {
+ $filename = basename( $file_path );
+
+ if ( strstr( $filename, '?' ) ) {
+ $filename = current( explode( '?', $filename ) );
+ }
+
+ return $filename;
+ }
+
/**
* Redirect to a file to start the download.
*
@@ -474,14 +491,41 @@ class WC_Download_Handler {
* @param string $filename File name.
*/
public static function download_file_force( $file_path, $filename ) {
+ // Raise the time limit and release the session before any potentially slow remote I/O below.
+ self::check_server_config();
+
$parsed_file_path = self::parse_file_path( $file_path );
$download_range = self::get_download_range( @filesize( $parsed_file_path['file_path'] ) ); // @codingStandardsIgnoreLine.
- self::download_headers( $parsed_file_path['file_path'], $filename, $download_range );
-
$start = isset( $download_range['start'] ) ? $download_range['start'] : 0;
$length = isset( $download_range['length'] ) ? $download_range['length'] : 0;
- if ( ! self::readfile_chunked( $parsed_file_path['file_path'], $start, $length ) ) {
+
+ if ( $parsed_file_path['remote_file'] ) {
+ // Open the remote file before sending our own headers, so the filename announced by
+ // the remote server in its response headers can be used when the URL contains none.
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming a remote file needs fopen (WP_Filesystem cannot stream); a false return is handled below.
+ $handle = @fopen( $parsed_file_path['file_path'], 'r' );
+
+ $served = false;
+ if ( false !== $handle ) {
+ $response_headers = stream_get_meta_data( $handle )['wrapper_data'] ?? array();
+ $filename = self::resolve_filename_from_response_headers(
+ is_array( $response_headers ) ? $response_headers : array(),
+ $filename,
+ // A filename customized via the `woocommerce_file_download_filename` filter (or by a
+ // custom caller of this method) is preserved and only completed with an extension.
+ self::derive_filename_from_file_path( $file_path ) !== $filename
+ );
+
+ self::download_headers( $parsed_file_path['file_path'], $filename, $download_range, true );
+ $served = self::readfile_from_handle( $handle, $start, $length );
+ }
+ } else {
+ self::download_headers( $parsed_file_path['file_path'], $filename, $download_range );
+ $served = self::readfile_chunked( $parsed_file_path['file_path'], $start, $length );
+ }
+
+ if ( ! $served ) {
if ( $parsed_file_path['remote_file'] && 'yes' === get_option( 'woocommerce_downloads_redirect_fallback_allowed' ) ) {
wc_get_logger()->warning(
sprintf(
@@ -499,6 +543,94 @@ class WC_Download_Handler {
exit;
}
+ /**
+ * Given the raw HTTP response header lines collected while opening a remote file (possibly
+ * spanning a redirect chain) and the filename derived from the URL, determine the most
+ * appropriate filename for serving the download.
+ *
+ * The filename derived from the URL wins when it already has an extension. Otherwise the
+ * filename announced by the remote server in the `Content-Disposition` header of the final
+ * response is used, falling back to appending an extension derived from its `Content-Type`
+ * header. This makes downloads hosted on URLs without a filename in their path (for example
+ * Google Drive links) save under a usable name instead of a bare URL segment like `uc`.
+ *
+ * @since 11.1.0
+ *
+ * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
+ *
+ * @param array $response_headers Raw HTTP response header lines, e.g. from `stream_get_meta_data()['wrapper_data']`.
+ * @param string $filename Filename derived from the URL.
+ * @param bool $preserve_filename When true, `$filename` is kept (it was customized deliberately, e.g. via the
+ * `woocommerce_file_download_filename` filter) and only completed with an
+ * extension derived from the response headers, instead of being replaced by
+ * the remote-announced filename.
+ * @return string
+ */
+ public static function resolve_filename_from_response_headers( array $response_headers, string $filename, bool $preserve_filename = false ): string {
+ if ( '' !== pathinfo( $filename, PATHINFO_EXTENSION ) ) {
+ return $filename;
+ }
+
+ // Only the headers of the final response in a redirect chain describe the actual file.
+ $headers = array();
+ foreach ( $response_headers as $header ) {
+ if ( ! is_string( $header ) ) {
+ continue;
+ }
+
+ if ( 0 === stripos( $header, 'HTTP/' ) ) {
+ $headers = array();
+ continue;
+ }
+
+ $parts = explode( ':', $header, 2 );
+ if ( 2 === count( $parts ) ) {
+ $headers[ strtolower( trim( $parts[0] ) ) ] = trim( $parts[1] );
+ }
+ }
+
+ $remote_filename = '';
+ if ( isset( $headers['content-disposition'] ) ) {
+ // The RFC 5987 encoded `filename*` parameter takes precedence over the plain `filename` one.
+ // Matches e.g. `filename*=UTF-8''My%20Report.pdf`: a charset, a language tag between the quotes, then the percent-encoded value.
+ if ( preg_match( '/filename\*\s*=\s*[^\']*\'[^\']*\'(?<encoded_value>[^;]+)/i', $headers['content-disposition'], $matches ) ) {
+ $remote_filename = rawurldecode( trim( $matches['encoded_value'], " \t\"" ) );
+ } elseif ( preg_match( '/filename\s*=\s*(?:"(?<quoted_value>[^"]*)"|(?<bare_value>[^;]+))/i', $headers['content-disposition'], $matches ) ) {
+ // Matches `filename="My Report.pdf"` (quoted) or `filename=report.pdf` (bare token, terminated by `;`).
+ $remote_filename = isset( $matches['bare_value'] ) ? trim( $matches['bare_value'] ) : $matches['quoted_value'];
+ }
+
+ $remote_filename = sanitize_file_name( $remote_filename );
+ }
+
+ if ( '' !== $remote_filename ) {
+ if ( $preserve_filename ) {
+ $remote_extension = pathinfo( $remote_filename, PATHINFO_EXTENSION );
+
+ if ( '' !== $remote_extension ) {
+ $filename .= '.' . $remote_extension;
+ }
+ } else {
+ $filename = $remote_filename;
+ }
+ }
+
+ if ( '' === pathinfo( $filename, PATHINFO_EXTENSION ) && isset( $headers['content-type'] ) ) {
+ $mime_type = strtolower( trim( (string) strtok( $headers['content-type'], ';' ) ) );
+
+ // application/octet-stream is generic and would map to an arbitrary extension.
+ if ( '' !== $mime_type && 'application/octet-stream' !== $mime_type ) {
+ $extension = wp_get_default_extension_for_mime_type( $mime_type );
+
+ if ( $extension ) {
+ $filename .= '.' . $extension;
+ }
+ }
+ }
+
+ return $filename;
+ }
+
/**
* Get content type of a download.
*
@@ -520,20 +652,59 @@ class WC_Download_Handler {
return $ctype;
}
+ /**
+ * Determine the Content-Type a download will be served with.
+ *
+ * The type is derived from the extension in the file path. The URL path of a remote file may
+ * not contain a usable extension; in that case the filename resolved from the remote response
+ * headers is used as a fallback — unless it maps to a type browsers may render or execute
+ * inline, since for remote files the extension can originate from data controlled by the
+ * remote server.
+ *
+ * @param string $file_path File path.
+ * @param string $filename Filename of the download.
+ * @param bool $remote_file Whether the download is a remote file.
+ * @return string
+ */
+ private static function get_content_type_for_served_download( $file_path, $filename, $remote_file ) {
+ $content_type = self::get_download_content_type( $file_path );
+
+ if ( $remote_file && 'application/force-download' === $content_type && $filename ) {
+ $filename_content_type = self::get_download_content_type( $filename );
+
+ $renderable_content_types = array(
+ 'text/html',
+ 'application/xhtml+xml',
+ 'image/svg+xml',
+ 'text/xml',
+ 'application/xml',
+ );
+
+ if ( ! in_array( $filename_content_type, $renderable_content_types, true ) ) {
+ $content_type = $filename_content_type;
+ }
+ }
+
+ return $content_type;
+ }
+
/**
* Set headers for the download.
*
* @param string $file_path File path.
* @param string $filename File name.
* @param array $download_range Array containing info about range download request (see {@see get_download_range} for structure).
+ * @param bool $remote_file Whether the download is a remote file.
*/
- private static function download_headers( $file_path, $filename, $download_range = array() ) {
+ private static function download_headers( $file_path, $filename, $download_range = array(), $remote_file = false ) {
self::check_server_config();
self::clean_buffers();
wc_nocache_headers();
+ $content_type = self::get_content_type_for_served_download( $file_path, $filename, $remote_file );
+
header( 'X-Robots-Tag: noindex, nofollow', true );
- header( 'Content-Type: ' . self::get_download_content_type( $file_path ) );
+ header( 'Content-Type: ' . $content_type );
header( 'Content-Description: File Transfer' );
header( 'Content-Disposition: ' . self::get_content_disposition() . '; filename="' . $filename . '";' );
header( 'Content-Transfer-Encoding: binary' );
@@ -618,9 +789,12 @@ class WC_Download_Handler {
* @return bool Success or fail
*/
public static function readfile_chunked( $file, $start = 0, $length = 0 ) {
+ // Define before attempting to open the file: the constant has always been defined even
+ // when the open fails, and external code may rely on that side effect.
if ( ! defined( 'WC_CHUNK_SIZE' ) ) {
define( 'WC_CHUNK_SIZE', 1024 * 1024 );
}
+
$handle = @fopen( $file, 'r' ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_read_fopen
if ( false === $handle ) {
@@ -628,7 +802,23 @@ class WC_Download_Handler {
}
if ( ! $length ) {
- $length = @filesize( $file ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
+ $length = (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Remote paths make filesize error; false is handled by the cast (0 means read until EOF).
+ }
+
+ return self::readfile_from_handle( $handle, $start, $length );
+ }
+
+ /**
+ * Read an already-open file handle in chunks and echo its content.
+ *
+ * @param resource $handle Open file handle, e.g. from `fopen()`.
+ * @param int $start Byte offset/position of the beginning from which to read from the file.
+ * @param int $length Length of the chunk to be read from the file in bytes, 0 means until the end of file.
+ * @return bool Success or fail
+ */
+ private static function readfile_from_handle( $handle, $start = 0, $length = 0 ) {
+ if ( ! defined( 'WC_CHUNK_SIZE' ) ) {
+ define( 'WC_CHUNK_SIZE', 1024 * 1024 );
}
$read_length = (int) WC_CHUNK_SIZE;
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php b/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php
index b11e00a976c..1afea5eb558 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php
@@ -221,6 +221,241 @@ class WC_Download_Handler_Tests extends \WC_Unit_Test_Case {
self::restore_download_handlers();
}
+ /**
+ * @testdox resolve_filename_from_response_headers() should keep the URL-derived filename when it already has an extension.
+ */
+ public function test_resolve_filename_keeps_filename_with_extension(): void {
+ $headers = array(
+ 'HTTP/1.1 200 OK',
+ 'Content-Disposition: attachment; filename="remote-name.pdf"',
+ );
+
+ $resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, 'local-name.zip' );
+
+ $this->assertSame( 'local-name.zip', $resolved, 'A filename that already has an extension should not be overridden.' );
+ }
+
+ /**
+ * @testdox resolve_filename_from_response_headers() should use the Content-Disposition filename when the URL-derived filename has no extension.
+ */
+ public function test_resolve_filename_uses_content_disposition_filename(): void {
+ $headers = array(
+ 'HTTP/1.1 200 OK',
+ 'Content-Type: application/pdf',
+ 'Content-Disposition: attachment; filename="My Report.pdf"',
+ );
+
+ $resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, 'uc' );
+
+ $this->assertSame( 'My-Report.pdf', $resolved, 'The sanitized Content-Disposition filename should be used.' );
+ }
+
+ /**
+ * @testdox resolve_filename_from_response_headers() should parse the unquoted token form of the filename parameter.
+ */
+ public function test_resolve_filename_parses_bare_token_filename(): void {
+ $headers = array(
+ 'HTTP/1.1 200 OK',
+ 'Content-Disposition: attachment; filename=Hello-World-master.zip',
+ );
+
+ $resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, 'master' );
+
+ $this->assertSame( 'Hello-World-master.zip', $resolved, 'The unquoted filename token form should be parsed.' );
+ }
+
+ /**
+ * @testdox resolve_filename_from_response_headers() should prefer the RFC 5987 filename* parameter and percent-decode it.
+ */
+ public function test_resolve_filename_prefers_rfc5987_filename(): void {
+ $headers = array(
+ 'HTTP/1.1 200 OK',
+ 'Content-Disposition: attachment; filename="fallback.pdf"; filename*=UTF-8\'\'My%20Report.pdf',
+ );
+
+ $resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, 'uc' );
+
+ $this->assertSame( 'My-Report.pdf', $resolved, 'The RFC 5987 filename* parameter should win over the plain filename parameter.' );
+ }
+
+ /**
+ * @testdox resolve_filename_from_response_headers() should handle the non-standard quoted form of the filename* parameter.
+ */
+ public function test_resolve_filename_handles_quoted_rfc5987_filename(): void {
+ $headers = array(
+ 'HTTP/1.1 200 OK',
+ 'Content-Disposition: attachment; filename*="UTF-8\'\'My%20Report.pdf"',
+ );
+
+ $resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, 'uc' );
+
+ $this->assertSame( 'My-Report.pdf', $resolved, 'The quoted filename* form emitted by some non-conforming servers should be parsed too.' );
+ }
+
+ /**
+ * @testdox resolve_filename_from_response_headers() should use the headers of the last response in a redirect chain.
+ */
+ public function test_resolve_filename_uses_last_response_of_redirect_chain(): void {
+ $headers = array(
+ 'HTTP/1.1 302 Found',
+ 'Location: https://cdn.example.com/get',
+ 'Content-Disposition: attachment; filename="wrong.pdf"',
+ 'HTTP/1.1 200 OK',
+ 'Content-Disposition: attachment; filename="right.pdf"',
+ );
+
+ $resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, 'uc' );
+
+ $this->assertSame( 'right.pdf', $resolved, 'Only the final response of the redirect chain should be considered.' );
+ }
+
+ /**
+ * @testdox resolve_filename_from_response_headers() should match header names and disposition parameters case-insensitively.
+ */
+ public function test_resolve_filename_is_case_insensitive(): void {
+ $headers = array(
+ 'HTTP/1.1 200 OK',
+ 'CONTENT-DISPOSITION: Attachment; FILENAME="Report.PDF"',
+ );
+
+ $resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, 'uc' );
+
+ $this->assertSame( 'Report.PDF', $resolved, 'Header and parameter matching should be case-insensitive.' );
+ }
+
+ /**
+ * @testdox resolve_filename_from_response_headers() should fall back to an extension derived from the Content-Type header when no Content-Disposition filename is available.
+ */
+ public function test_resolve_filename_falls_back_to_content_type_extension(): void {
+ $headers = array(
+ 'HTTP/1.1 200 OK',
+ 'Content-Type: application/pdf; charset=binary',
+ );
+
+ $resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, 'uc' );
+
+ $this->assertSame( 'uc.pdf', $resolved, 'The extension should be derived from the Content-Type header.' );
+ }
+
+ /**
+ * @testdox resolve_filename_from_response_headers() should return the original filename when the response headers contain nothing usable.
+ */
+ public function test_resolve_filename_returns_original_when_headers_unusable(): void {
+ $headers = array(
+ 'HTTP/1.1 200 OK',
+ 'Content-Type: application/octet-stream',
+ );
+
+ $resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, 'uc' );
+
+ $this->assertSame( 'uc', $resolved, 'The original filename should be kept when the headers provide no better information.' );
+ }
+
+ /**
+ * @testdox resolve_filename_from_response_headers() should sanitize characters that are unsafe in a filename or header value.
+ */
+ public function test_resolve_filename_sanitizes_unsafe_characters(): void {
+ $headers = array(
+ 'HTTP/1.1 200 OK',
+ 'Content-Disposition: attachment; filename*=UTF-8\'\'..%2F..%2Fevil%22name.pdf',
+ );
+
+ $resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, 'uc' );
+
+ $this->assertSame( 'evilname.pdf', $resolved, 'Path traversal sequences and quotes should be stripped from the filename.' );
+ }
+
+ /**
+ * @testdox resolve_filename_from_response_headers() should only append the remote extension to a filename marked as preserved.
+ */
+ public function test_resolve_filename_preserves_customized_filename(): void {
+ $headers = array(
+ 'HTTP/1.1 200 OK',
+ 'Content-Disposition: attachment; filename="My Report.pdf"',
+ );
+
+ $resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, 'Seasons-Catalog', true );
+
+ $this->assertSame( 'Seasons-Catalog.pdf', $resolved, 'A preserved filename should keep its name and only gain the remote extension.' );
+ }
+
+ /**
+ * @testdox resolve_filename_from_response_headers() should complete a preserved filename from the Content-Type header when the Content-Disposition filename is absent.
+ */
+ public function test_resolve_filename_preserves_customized_filename_with_content_type_fallback(): void {
+ $headers = array(
+ 'HTTP/1.1 200 OK',
+ 'Content-Type: application/zip',
+ );
+
+ $resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, 'Seasons-Catalog', true );
+
+ $this->assertSame( 'Seasons-Catalog.zip', $resolved, 'A preserved filename should gain the extension derived from the Content-Type header.' );
+ }
+
+ /**
+ * @testdox resolve_filename_from_response_headers() should leave a preserved filename untouched when the remote filename has no extension either.
+ */
+ public function test_resolve_filename_preserved_filename_untouched_without_remote_extension(): void {
+ $headers = array(
+ 'HTTP/1.1 200 OK',
+ 'Content-Disposition: attachment; filename="report"',
+ );
+
+ $resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, 'Seasons-Catalog', true );
+
+ $this->assertSame( 'Seasons-Catalog', $resolved, 'An extensionless remote filename provides nothing to complete a preserved filename with.' );
+ }
+
+ /**
+ * @testdox download_file_force() should render an error page, not a download, when the remote file cannot be opened.
+ */
+ public function test_download_file_force_shows_error_when_remote_file_cannot_be_opened(): void {
+ update_option( 'woocommerce_downloads_redirect_fallback_allowed', 'no' );
+
+ $this->expectException( WPDieException::class );
+ $this->expectExceptionMessageMatches( '/File not found/' );
+
+ // Port 1 is never listening, so the remote open fails immediately.
+ WC_Download_Handler::download_file_force( 'http://127.0.0.1:1/missing-file', 'missing-file' );
+ }
+
+ /**
+ * @testdox The Content-Type fallback to the resolved filename should apply to remote files only.
+ */
+ public function test_content_type_fallback_applies_only_to_remote_files(): void {
+ $method = new ReflectionMethod( WC_Download_Handler::class, 'get_content_type_for_served_download' );
+ $method->setAccessible( true );
+
+ $this->assertSame(
+ 'application/pdf',
+ $method->invoke( null, 'https://drive.google.com/uc', 'My-Report.pdf', true ),
+ 'For remote files the Content-Type should fall back to the resolved filename.'
+ );
+ $this->assertSame(
+ 'application/force-download',
+ $method->invoke( null, '/some/local/file', 'My-Report.pdf', false ),
+ 'For local files the Content-Type should be derived from the file path only.'
+ );
+ }
+
+ /**
+ * @testdox The Content-Type fallback should not serve types browsers may render inline, since the extension can come from the remote server.
+ */
+ public function test_content_type_fallback_rejects_renderable_types(): void {
+ // Only users with unfiltered_html have text/html in their allowed mime types at all.
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+
+ $method = new ReflectionMethod( WC_Download_Handler::class, 'get_content_type_for_served_download' );
+ $method->setAccessible( true );
+
+ $this->assertSame(
+ 'application/force-download',
+ $method->invoke( null, 'https://evil.example.com/uc', 'payload.html', true ),
+ 'A remote-derived .html filename should not switch the response to text/html.'
+ );
+ }
+
/**
* Creates a downloadable product, and then places (and completes) an order for that
* object.