Commit 19bb1290ef5 for woocommerce

commit 19bb1290ef5182449751ac9229de0ddbf62d73a4
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date:   Thu Jul 16 15:12:48 2026 +0300

    Fix TypeError on remote force-download with non-string filename (#66658)

    * Fix TypeError on remote force-download with non-string filename

    The woocommerce_file_download_filename filter documents a string return,
    but core historically tolerated non-string returns (commonly null from a
    callback that forgets to return): the value only ever reached a string
    concatenation in the Content-Disposition header, so the file was still
    served.

    Routing the remote force-download branch through the strictly typed
    resolve_filename_from_response_headers() turned that tolerance into an
    uncaught TypeError: customers of stores serving remote downloadables get
    a fatal error instead of their file whenever a misbehaving filter is
    active.

    Coerce the filename inside resolve_filename_from_response_headers()
    (non-scalars become an empty string) instead of fataling. An empty
    filename carries nothing to preserve, so the remote-announced filename
    now wins, which also yields a better name than the pre-existing behavior
    of serving with an empty filename header.

    * Add changelog entry for remote download filename TypeError fix

    * Render Stringable download filenames instead of discarding them

    The is_scalar() guard added to resolve_filename_from_response_headers()
    is false for every object, including one declaring __toString(). That
    made the coercion narrower than the two behaviors it stands in for: both
    the pre-11.1.0 string concatenation and the strictly typed parameter
    introduced by #66604 render such an object, since neither runs under
    strict_types. Only null and arrays ever fataled.

    A filter callback returning a filename value object therefore had its
    name silently reduced to an empty string, which in turn cleared
    $preserve_filename and let the remote-announced name overwrite it. That
    is the same class of silent wrong-filename bug this fix exists to
    prevent, one type over.

    Admit __toString() objects alongside scalars. method_exists() is used
    rather than instanceof Stringable because that interface is PHP 8.0+ and
    WooCommerce supports PHP 7.4.

    * Add integration test for force-downloading with a broken filename filter

    The existing regression tests call resolve_filename_from_response_headers()
    directly, so nothing covered the chain #66635 actually reports: the
    woocommerce_file_download_filename filter, through do_action, into
    download_file_force(), and on to the remote branch. A future change could
    restore the strict parameter type, or move the coercion, without any test
    noticing.

    Two obstacles made this awkward. The existing remote test points at a
    closed port, so the open fails long before the filename is resolved; a
    stream wrapper standing in for the host gets us past that. And the success
    path ends in exit(), which would take the PHPUnit process down, so the
    test unwinds at the first hook after the filename is resolved: the
    upload_mimes filter, which download_headers() reaches via
    get_allowed_mime_types() before sending any header.

    Verified to fail against the pre-fix handler with the reported TypeError,
    rather than passing vacuously.

    * Declare strict types in the fake remote stream helper

    Matches the project convention for PHPUnit test files. The helper declares
    no parameter or return types and calls nothing, so this is a no-op at
    runtime; the suite is unchanged.

    Raised in review by CodeRabbit.

    * Detect __toString() with is_callable() rather than method_exists()

    method_exists() reports non-public methods too, so a filter returning an
    object whose __toString() is not public would satisfy the guard and then
    fatal on the cast. PHP 8 rejects such a class when it is compiled, but
    PHP 7.4 is still supported and accepts it, so the guard has to be
    visibility-aware. is_callable() is, and behaves identically everywhere
    else.

    Raised in review.

    * Tidy the download handler test double and its integration test

    Rename the stream wrapper helper to match the PascalCase, unprefixed
    naming every other file in tests/php/helpers uses, and record that PHP
    hands user-space wrappers their own instance as wrapper_data, so the
    double cannot stand in for a remote server's response headers.

    Drop the $this->fail() call: PHPUnit's AssertionFailedError descends from
    RuntimeException, so the catch on the next line would have swallowed it
    and reported a confusing message. The assertion after the block already
    covers that path. Also note in place that the marker hook has to stay
    ahead of the terminating exit().

    Raised in review.

    * Report invalid download filename filter values

    ---------

    Co-authored-by: Vlad Olaru <vlad.olaru@automattic.com>

diff --git a/plugins/woocommerce/changelog/fix-66635-force-download-filename-typeerror b/plugins/woocommerce/changelog/fix-66635-force-download-filename-typeerror
new file mode 100644
index 00000000000..f39a9d31a69
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-66635-force-download-filename-typeerror
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent a fatal TypeError when force-downloading a remote file while a woocommerce_file_download_filename filter callback returns a non-string value.
diff --git a/plugins/woocommerce/includes/class-wc-download-handler.php b/plugins/woocommerce/includes/class-wc-download-handler.php
index 0c66bb4d85e..0f813861d4b 100644
--- a/plugins/woocommerce/includes/class-wc-download-handler.php
+++ b/plugins/woocommerce/includes/class-wc-download-handler.php
@@ -558,15 +558,43 @@ class WC_Download_Handler {
 	 *
 	 * @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.
+	 * @param array $response_headers  Raw HTTP response header lines, e.g. from `stream_get_meta_data()['wrapper_data']`.
+	 * @param mixed $filename          Filename derived from the URL. Documented as a string, but a misbehaving
+	 *                                 `woocommerce_file_download_filename` filter callback may produce any type.
+	 *                                 Anything renderable as a string (scalars, and objects declaring
+	 *                                 `__toString()`) is rendered, matching what string concatenation used to do
+	 *                                 with it; anything else (`null`, arrays) becomes '' instead of fataling. A
+	 *                                 non-string value triggers an incorrect usage notice identifying its type.
+	 * @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. Ignored when `$filename` renders empty, since
+	 *                                 there is then nothing to preserve.
 	 * @return string
 	 */
-	public static function resolve_filename_from_response_headers( array $response_headers, string $filename, bool $preserve_filename = false ): string {
+	public static function resolve_filename_from_response_headers( array $response_headers, $filename, bool $preserve_filename = false ): string {
+		if ( ! is_string( $filename ) ) {
+			$original_type = gettype( $filename );
+
+			// `is_scalar()` is false for objects, so callable `__toString()` implementations are admitted separately.
+			// `instanceof Stringable` cannot be used here: it is PHP 8.0+, and WooCommerce supports PHP 7.4.
+			$filename = is_scalar( $filename ) || ( is_object( $filename ) && is_callable( array( $filename, '__toString' ) ) )
+				? (string) $filename
+				: '';
+
+			wc_doing_it_wrong(
+				__METHOD__,
+				sprintf(
+					'The woocommerce_file_download_filename filter should return a string; %s returned.',
+					$original_type
+				),
+				'11.1.0'
+			);
+		}
+
+		// An empty filename carries nothing to preserve, so let the remote-announced filename win.
+		$preserve_filename = $preserve_filename && '' !== $filename;
+
 		if ( '' !== pathinfo( $filename, PATHINFO_EXTENSION ) ) {
 			return $filename;
 		}
diff --git a/plugins/woocommerce/tests/php/helpers/FakeRemoteStreamWrapper.php b/plugins/woocommerce/tests/php/helpers/FakeRemoteStreamWrapper.php
new file mode 100644
index 00000000000..b5cbd52d312
--- /dev/null
+++ b/plugins/woocommerce/tests/php/helpers/FakeRemoteStreamWrapper.php
@@ -0,0 +1,99 @@
+<?php
+/**
+ * Fake remote stream helper.
+ *
+ * @package WooCommerce\Tests
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Minimal stream wrapper standing in for a reachable remote file, so download handling can be
+ * exercised without network access. Register it over `http` for the duration of a test:
+ *
+ *     stream_wrapper_unregister( 'http' );
+ *     stream_wrapper_register( 'http', FakeRemoteStreamWrapper::class );
+ *     // ...
+ *     stream_wrapper_restore( 'http' );
+ *
+ * Only the operations the download handler performs are implemented; the stream is always empty.
+ * Parameter names and signatures are fixed by PHP's streamWrapper prototype, so unused ones are
+ * expected: https://www.php.net/manual/en/class.streamwrapper.php
+ *
+ * Note that PHP reports `stream_get_meta_data()['wrapper_data']` as the wrapper instance itself for
+ * user-space wrappers, never the array of raw header lines the built-in `http` wrapper provides.
+ * Code reading response headers therefore sees none of them, so this double cannot stand in for a
+ * remote server's `Content-Disposition` or `Content-Type`.
+ *
+ * phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter
+ */
+class FakeRemoteStreamWrapper {
+
+	/**
+	 * Stream context, assigned by PHP when the wrapper is instantiated.
+	 *
+	 * @var resource
+	 */
+	public $context;
+
+	/**
+	 * Open the stream.
+	 *
+	 * @param string $path        File path.
+	 * @param string $mode        Mode the stream is opened in.
+	 * @param int    $options     Stream options.
+	 * @param string $opened_path Path actually opened.
+	 * @return bool
+	 */
+	public function stream_open( $path, $mode, $options, &$opened_path ) {
+		return true;
+	}
+
+	/**
+	 * Read from the stream.
+	 *
+	 * @param int $count Bytes to read.
+	 * @return string
+	 */
+	public function stream_read( $count ) {
+		return '';
+	}
+
+	/**
+	 * Whether the end of the stream has been reached.
+	 *
+	 * @return bool
+	 */
+	public function stream_eof() {
+		return true;
+	}
+
+	/**
+	 * Stat the open stream.
+	 *
+	 * @return array
+	 */
+	public function stream_stat() {
+		return array();
+	}
+
+	/**
+	 * Close the stream.
+	 *
+	 * @return bool
+	 */
+	public function stream_close() {
+		return true;
+	}
+
+	/**
+	 * Stat the given path.
+	 *
+	 * @param string $path  File path.
+	 * @param int    $flags Stat flags.
+	 * @return array
+	 */
+	public function url_stat( $path, $flags ) {
+		return array();
+	}
+}
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 1afea5eb558..8dab580f0fc 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
@@ -407,6 +407,133 @@ class WC_Download_Handler_Tests extends \WC_Unit_Test_Case {
 		$this->assertSame( 'Seasons-Catalog', $resolved, 'An extensionless remote filename provides nothing to complete a preserved filename with.' );
 	}

+	/**
+	 * @testdox resolve_filename_from_response_headers() should tolerate a null filename, as produced by a broken `woocommerce_file_download_filename` filter callback, instead of throwing a TypeError.
+	 */
+	public function test_resolve_filename_tolerates_null_filename(): void {
+		$headers = array(
+			'HTTP/1.1 200 OK',
+			'Content-Disposition: attachment; filename="report.pdf"',
+		);
+		$this->setExpectedIncorrectUsage( 'WC_Download_Handler::resolve_filename_from_response_headers' );
+
+		$resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, null, true );
+
+		$this->assertSame( 'report.pdf', $resolved, 'A null filename cannot be preserved, so the remote-announced filename should be used.' );
+	}
+
+	/**
+	 * @testdox resolve_filename_from_response_headers() should treat a non-scalar filename as empty instead of throwing a TypeError.
+	 */
+	public function test_resolve_filename_tolerates_non_scalar_filename(): void {
+		$incorrect_usage = array();
+		$listener        = function ( $function_name, $message, $version ) use ( &$incorrect_usage ) {
+			$incorrect_usage = array(
+				'function_name' => $function_name,
+				'message'       => $message,
+				'version'       => $version,
+			);
+		};
+		add_action( 'doing_it_wrong_run', $listener, 10, 3 );
+		add_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+		$this->setExpectedIncorrectUsage( 'WC_Download_Handler::resolve_filename_from_response_headers' );
+
+		try {
+			$resolved = WC_Download_Handler::resolve_filename_from_response_headers( array( 'HTTP/1.1 200 OK' ), array( 'not-a-filename' ) );
+		} finally {
+			remove_action( 'doing_it_wrong_run', $listener, 10 );
+			remove_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+		}
+
+		$this->assertSame( '', $resolved, 'A non-scalar filename with no usable response headers should resolve to an empty string.' );
+		$this->assertSame( 'WC_Download_Handler::resolve_filename_from_response_headers', $incorrect_usage['function_name'] );
+		$this->assertStringContainsString( 'woocommerce_file_download_filename filter should return a string; array returned.', $incorrect_usage['message'] );
+		$this->assertSame( '11.1.0', $incorrect_usage['version'] );
+	}
+
+	/**
+	 * @testdox resolve_filename_from_response_headers() should render a filename object that declares __toString(), as string concatenation always did.
+	 */
+	public function test_resolve_filename_renders_stringable_filename(): void {
+		$headers = array(
+			'HTTP/1.1 200 OK',
+			'Content-Disposition: attachment; filename="Quarterly Report.pdf"',
+		);
+
+		$filename = new class() {
+			/**
+			 * Render the filename.
+			 *
+			 * @return string
+			 */
+			public function __toString(): string {
+				return 'Seasons-Catalog';
+			}
+		};
+		$this->setExpectedIncorrectUsage( 'WC_Download_Handler::resolve_filename_from_response_headers' );
+
+		$resolved = WC_Download_Handler::resolve_filename_from_response_headers( $headers, $filename, true );
+
+		$this->assertSame( 'Seasons-Catalog.pdf', $resolved, 'A filename object declaring __toString() should be preserved and gain the remote extension, not be discarded.' );
+	}
+
+	/**
+	 * @testdox download_file_force() should carry a non-string filename from a woocommerce_file_download_filename callback through to the download headers instead of fataling.
+	 */
+	public function test_download_file_force_tolerates_non_string_filename_from_filter(): void {
+		// Mirrors the report in #66635: a callback that falls off the end and returns null.
+		$broken_filename_filter = function () {};
+
+		$reached_headers = false;
+		// download_headers() derives the content type from the resolved filename via
+		// get_allowed_mime_types(), so this fires once the filename is resolved but before any
+		// header() call. Throwing here unwinds download_file_force() ahead of its terminating
+		// exit(), which would otherwise take the PHPUnit process down with it.
+		//
+		// That ordering is what this test depends on: should download_headers() ever be reworked so
+		// that nothing hooks in ahead of the exit(), this test would take the test run down with it
+		// rather than fail. Keep the marker on the earliest hook that follows the filename being
+		// resolved.
+		$header_stage_marker = function () use ( &$reached_headers ) {
+			$reached_headers = true;
+			throw new RuntimeException( 'reached-download-headers' );
+		};
+
+		add_filter( 'woocommerce_file_download_filename', $broken_filename_filter );
+		add_filter( 'upload_mimes', $header_stage_marker );
+		$this->setExpectedIncorrectUsage( 'WC_Download_Handler::resolve_filename_from_response_headers' );
+
+		$ob_level = ob_get_level();
+
+		// Stand in for the remote host so the open succeeds and the filename actually reaches
+		// resolve_filename_from_response_headers(), which a real unreachable URL never would.
+		stream_wrapper_unregister( 'http' );
+		stream_wrapper_register( 'http', FakeRemoteStreamWrapper::class );
+
+		try {
+			// No $this->fail() for the no-throw case: PHPUnit's own AssertionFailedError descends
+			// from RuntimeException, so the catch below would swallow it. The assertion after this
+			// block covers that path instead.
+			WC_Download_Handler::download( 'http://example.test/uc', 0 );
+		} catch ( RuntimeException $e ) {
+			$this->assertSame( 'reached-download-headers', $e->getMessage(), 'Only the marker exception should escape; a TypeError here is the #66635 regression.' );
+		} finally {
+			stream_wrapper_restore( 'http' );
+
+			// download_headers() unwinds every output buffer, including the one PHPUnit wraps
+			// each test in, so restore the nesting level it expects to find on the way out.
+			while ( ob_get_level() < $ob_level ) {
+				ob_start();
+			}
+
+			// Remove only what this test added: `upload_mimes` is a shared WordPress hook.
+			remove_filter( 'woocommerce_file_download_filename', $broken_filename_filter );
+			remove_filter( 'upload_mimes', $header_stage_marker );
+		}
+
+		$this->assertTrue( $reached_headers, 'A null filename should flow through to the download headers instead of throwing a TypeError.' );
+	}
+
 	/**
 	 * @testdox download_file_force() should render an error page, not a download, when the remote file cannot be opened.
 	 */