Commit f5df31a690f for woocommerce

commit f5df31a690f58216d8457cac117018d3ec91a7e7
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date:   Thu Oct 29 13:17:16 2026 +0200

    Fix CSV importer server paths when wp-content is outside ABSPATH (#66557)

    * fix: resolve product CSV importer server paths against site root and uploads dir

    The product CSV importer prefixed the entered server path with ABSPATH,
    so imports failed on hosts where wp-content lives outside of ABSPATH
    (e.g. installs with a symlinked core).

    Add FilesystemUtil::resolve_upload_file_path(), which resolves a
    user-supplied path against the WordPress root, the site root (parent of
    wp-content), and the uploads directory, accepting absolute paths and
    never probing user-supplied stream URLs. Wire the product CSV importer
    controller to resolve-then-validate through it, and update the import
    form hint to drop the hardcoded ABSPATH prefix.

    Fixes #53676.

    * test: pin CSV import path precedence and non-string file_url handling

    * chore: drop resolved phpstan baseline entry for CSV importer controller

    * docs: mark resolve_upload_file_path as @internal instead of @since

    * fix: only resolve CSV import server paths that pass the upload allowlist

    The first readable candidate could shadow a valid copy under uploads
    with a same-named file outside the allowlist (e.g. at the site root on
    symlinked-core hosts). Skip readable candidates that fail
    validate_upload_file_path() and short-circuit paths containing NUL
    bytes, which made path_is_absolute() throw a ValueError.

    * fix: simplify the CSV importer server path field

    Drop the bordered wrapper label and its styles now that the ABSPATH
    prefix is gone, and use a standard text field with a description.

    * fix: match the CSV importer path hint size to the other hints on the screen

    * fix: keep the CSV importer path example at the hint's font size

    * test: move the CSV importer server path tests out of the legacy suite

    * fix: name the allowed locations in the CSV importer server path hint

    * fix: keep the CSV importer file path wrapper styles for third-party importers

    * docs: clarify which markup the importer small code styles target

    * docs: note the importer file URL wrapper styles are kept for back-compat

diff --git a/plugins/woocommerce/changelog/fix-53676-csv-import-server-path b/plugins/woocommerce/changelog/fix-53676-csv-import-server-path
new file mode 100644
index 00000000000..6a36954e0c5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-53676-csv-import-server-path
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix server-path CSV product imports on sites where wp-content is outside the WordPress directory.
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index 5d287d21f69..6b6d06c8ff8 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -8848,6 +8848,7 @@ table.bar_chart {
 				margin: 0;
 			}

+			// Unused by core; kept for third-party importers that still output this markup.
 			.woocommerce-importer-file-url-field-wrapper {
 				border: 1px solid #ddd;
 				box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.07);
@@ -8876,6 +8877,11 @@ table.bar_chart {
 					min-width: 100%;
 				}
 			}
+
+			small code {
+				font-size: inherit;
+				white-space: nowrap;
+			}
 		}

 		@media screen and (min-width: 783px) {
diff --git a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
index c19ad9ae2e9..77dce95a308 100644
--- a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
+++ b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
@@ -637,10 +637,13 @@ class WC_Product_CSV_Importer_Controller {
 	public function handle_upload() {
 		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce already verified in WC_Product_CSV_Importer_Controller::upload_form_handler()
 		$file_url = isset( $_POST['file_url'] ) ? wc_clean( wp_unslash( $_POST['file_url'] ) ) : '';
+		if ( ! is_string( $file_url ) ) {
+			$file_url = '';
+		}

 		try {
 			if ( ! empty( $file_url ) ) {
-				$path = ABSPATH . $file_url;
+				$path = FilesystemUtil::resolve_upload_file_path( $file_url );
 				self::validate_file_path( $path );
 			} else {
 				$csv_import_util = wc_get_container()->get( Automattic\WooCommerce\Internal\Admin\ImportExport\CSVUploadHelper::class );
diff --git a/plugins/woocommerce/includes/admin/importers/views/html-product-csv-import-form.php b/plugins/woocommerce/includes/admin/importers/views/html-product-csv-import-form.php
index 0120b014cbe..7f32533eb5f 100644
--- a/plugins/woocommerce/includes/admin/importers/views/html-product-csv-import-form.php
+++ b/plugins/woocommerce/includes/admin/importers/views/html-product-csv-import-form.php
@@ -65,9 +65,17 @@ if ( ! defined( 'ABSPATH' ) ) {
 						<label for="woocommerce-importer-file-url"><?php esc_html_e( 'Alternatively, enter the path to a CSV file on your server:', 'woocommerce' ); ?></label>
 					</th>
 					<td>
-						<label for="woocommerce-importer-file-url" class="woocommerce-importer-file-url-field-wrapper">
-							<code><?php echo esc_html( ABSPATH ) . ' '; ?></code><input type="text" id="woocommerce-importer-file-url" name="file_url" />
-						</label>
+						<input type="text" id="woocommerce-importer-file-url" name="file_url" class="regular-text code" />
+						<br>
+						<small>
+							<?php
+							printf(
+								/* translators: %s: example relative file path. */
+								esc_html__( 'Absolute or relative path to a CSV file in your WordPress or uploads directory, for example %s.', 'woocommerce' ),
+								'<code>wp-content/uploads/products.csv</code>'
+							);
+							?>
+						</small>
 					</td>
 				</tr>
 				<tr class="woocommerce-importer-advanced hidden">
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 52309f92bdb..c750ed03bd8 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -3936,12 +3936,6 @@ parameters:
 			count: 1
 			path: includes/admin/helper/views/html-section-notices.php

-		-
-			message: '#^Binary operation "\." between string and non\-empty\-array\|non\-falsy\-string results in an error\.$#'
-			identifier: binaryOp.invalid
-			count: 1
-			path: includes/admin/importers/class-wc-product-csv-importer-controller.php
-
 		-
 			message: '#^Method WC_Product_CSV_Importer_Controller\:\:add_error\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php b/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php
index ddcc15e3c25..18d29c6ef38 100644
--- a/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php
@@ -313,6 +313,79 @@ class FilesystemUtil {
 		}
 	}

+	/**
+	 * Resolve a user-supplied file path to an absolute path on the server.
+	 *
+	 * Relative paths are tried against the WordPress root (ABSPATH), the
+	 * directory containing wp-content (the site root on hosts where wp-content
+	 * lives outside of ABSPATH, e.g. installs with a symlinked core), and the
+	 * uploads directory, in that order; the first candidate that is readable
+	 * AND within a location permitted by validate_upload_file_path() (ABSPATH
+	 * or the uploads directory) wins. Absolute local paths are used as-is when
+	 * they qualify. Stream URLs are never used as-is: probing a user-supplied
+	 * stream (e.g. phar://) with is_readable() can trigger wrapper side
+	 * effects, and stream-backed uploads (e.g. s3://) are already reachable
+	 * through the uploads-basedir candidate.
+	 *
+	 * The resolution bases are intentionally broader than the validation
+	 * allowlist (e.g. the site root is tried so that `wp-content/uploads/x.csv`
+	 * resolves on symlinked-core hosts), so a readable candidate outside the
+	 * allowlist is skipped rather than returned; callers should still pass the
+	 * result through validate_upload_file_path() before use.
+	 *
+	 * @internal
+	 *
+	 * @param string $file_path The path to resolve, as entered by the user.
+	 * @return string The resolved path. Falls back to the path relative to
+	 *                ABSPATH when no candidate qualifies, so validation of
+	 *                the returned value fails with the usual error.
+	 */
+	public static function resolve_upload_file_path( string $file_path ): string {
+		$abspath        = (string) Constants::get_constant( 'ABSPATH' );
+		$wp_content_dir = (string) Constants::get_constant( 'WP_CONTENT_DIR' );
+		$candidates     = array();
+
+		// path_is_absolute() calls realpath(), which throws on NUL bytes.
+		if ( false !== strpos( $file_path, "\0" ) ) {
+			return $abspath . $file_path; // Let validate_upload_file_path() reject it with the usual error.
+		}
+
+		// The stream check must come first: path_is_absolute() probes streams
+		// with is_file(), which must not run on user-supplied stream URLs.
+		if ( ! wp_is_stream( $file_path ) && path_is_absolute( $file_path ) ) {
+			$candidates[] = $file_path;
+		}
+
+		$bases = array(
+			$abspath,
+			trailingslashit( dirname( $wp_content_dir ) ),
+		);
+
+		$upload_dir = wp_get_upload_dir();
+		if ( false === $upload_dir['error'] ) {
+			$bases[] = trailingslashit( $upload_dir['basedir'] );
+		}
+
+		foreach ( $bases as $base ) {
+			$candidates[] = $base . $file_path;
+		}
+
+		$wp_filesystem = self::get_wp_filesystem_direct();
+		foreach ( $candidates as $candidate ) {
+			if ( ! $wp_filesystem->is_readable( $candidate ) ) {
+				continue;
+			}
+			try {
+				self::validate_upload_file_path( $candidate );
+				return $candidate;
+			} catch ( \Exception $e ) {
+				continue;
+			}
+		}
+
+		return $abspath . $file_path;
+	}
+
 	/**
 	 * Check if a given file is inside a given directory.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php b/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
index 41a8c81a8ec..afb69a63c5f 100644
--- a/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
@@ -747,4 +747,88 @@ class WC_Product_CSV_Importer_Controller_Test extends WC_Unit_Test_Case {
 		}
 		$this->assertSame( 'Commodity code (HS code)', $options['customs_commodity_code'], 'The commodity code option should match the export label.' );
 	}
+
+	/**
+	 * Path to the sample CSV used by the server-path tests.
+	 *
+	 * @return string
+	 */
+	private function sample_csv(): string {
+		return WC_Unit_Tests_Bootstrap::instance()->plugin_dir . '/tests/php/includes/importer/sample.csv';
+	}
+
+	/**
+	 * Test importing a file located in the uploads directory by entering a path
+	 * relative to the uploads directory (e.g. on hosts where wp-content lives
+	 * outside of ABSPATH).
+	 *
+	 * @return void
+	 */
+	public function test_server_file_in_uploads_dir() {
+		$upload_dir = wp_get_upload_dir();
+		$file       = trailingslashit( $upload_dir['basedir'] ) . 'sample-53676.csv';
+		self::file_copy( $this->sample_csv(), $file );
+
+		$_POST['file_url'] = 'sample-53676.csv';
+		$import_controller = new WC_Product_CSV_Importer_Controller();
+		try {
+			$this->assertEquals( $file, $import_controller->handle_upload() );
+		} finally {
+			wp_delete_file( $file );
+		}
+	}
+
+	/**
+	 * Test importing a file by entering its absolute path.
+	 *
+	 * @return void
+	 */
+	public function test_server_file_absolute_path() {
+		$upload_dir = wp_get_upload_dir();
+		$file       = trailingslashit( $upload_dir['basedir'] ) . 'sample-53676-absolute.csv';
+		self::file_copy( $this->sample_csv(), $file );
+
+		$_POST['file_url'] = $file;
+		$import_controller = new WC_Product_CSV_Importer_Controller();
+		try {
+			$this->assertEquals( $file, $import_controller->handle_upload() );
+		} finally {
+			wp_delete_file( $file );
+		}
+	}
+
+	/**
+	 * Test that an absolute path outside of ABSPATH and the uploads directory is rejected.
+	 *
+	 * @return void
+	 */
+	public function test_server_file_absolute_path_outside_allowed_locations() {
+		$file = sys_get_temp_dir() . '/sample-53676-outside.csv';
+		self::file_copy( $this->sample_csv(), $file );
+
+		$_POST['file_url'] = $file;
+		$import_controller = new WC_Product_CSV_Importer_Controller();
+		try {
+			$import_result = $import_controller->handle_upload();
+
+			$this->assertTrue( is_wp_error( $import_result ) );
+			$this->assertEquals( 'woocommerce_product_csv_importer_upload_invalid_file', $import_result->get_error_code() );
+		} finally {
+			wp_delete_file( $file );
+		}
+	}
+
+	/**
+	 * Test that a non-string file_url value is treated as empty rather than resolved.
+	 *
+	 * @return void
+	 */
+	public function test_server_file_non_string_file_url() {
+		$_POST['file_url'] = array( 'sample.csv' );
+		$import_controller = new WC_Product_CSV_Importer_Controller();
+		$import_result     = $import_controller->handle_upload();
+
+		$this->assertTrue( is_wp_error( $import_result ) );
+		$this->assertEquals( 'woocommerce_product_csv_importer_upload_invalid_file', $import_result->get_error_code() );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php
index 7d8ba284093..79fee616ce9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php
@@ -336,6 +336,144 @@ class FilesystemUtilTest extends WC_Unit_Test_Case {
 		FilesystemUtil::validate_upload_file_path( 'file://' . ABSPATH . 'index.php' );
 	}

+	/**
+	 * @testdox 'resolve_upload_file_path' resolves a path relative to ABSPATH.
+	 */
+	public function test_resolve_upload_file_path_relative_to_abspath(): void {
+		$this->assertSame( ABSPATH . 'index.php', FilesystemUtil::resolve_upload_file_path( 'index.php' ) );
+	}
+
+	/**
+	 * @testdox 'resolve_upload_file_path' returns a readable absolute path as-is.
+	 */
+	public function test_resolve_upload_file_path_absolute(): void {
+		$upload_dir = wp_get_upload_dir();
+		$path       = $this->make_temp_file( $upload_dir['basedir'] );
+
+		$this->assertSame( $path, FilesystemUtil::resolve_upload_file_path( $path ) );
+	}
+
+	/**
+	 * @testdox 'resolve_upload_file_path' resolves a path relative to the uploads directory.
+	 */
+	public function test_resolve_upload_file_path_relative_to_uploads(): void {
+		$upload_dir = wp_get_upload_dir();
+		$path       = $this->make_temp_file( $upload_dir['basedir'] );
+
+		$this->assertSame( $path, FilesystemUtil::resolve_upload_file_path( basename( $path ) ) );
+	}
+
+	/**
+	 * @testdox 'resolve_upload_file_path' resolves a path relative to the site root when wp-content lives outside ABSPATH (e.g. symlinked-core hosts).
+	 */
+	public function test_resolve_upload_file_path_relative_to_site_root(): void {
+		$site_root   = sys_get_temp_dir() . '/wc-fsutil-siteroot-' . wp_generate_uuid4();
+		$uploads_dir = $site_root . '/wp-content/uploads';
+		$path        = $this->make_temp_file( $uploads_dir );
+
+		$this->temp_dirs[] = $site_root . '/wp-content';
+		$this->temp_dirs[] = $site_root;
+
+		Constants::set_constant( 'WP_CONTENT_DIR', $site_root . '/wp-content' );
+		add_filter(
+			'upload_dir',
+			function ( $dirs ) use ( $uploads_dir ) {
+				$dirs['basedir'] = $uploads_dir;
+				$dirs['path']    = $uploads_dir;
+				return $dirs;
+			}
+		);
+
+		$this->assertSame( $path, FilesystemUtil::resolve_upload_file_path( 'wp-content/uploads/' . basename( $path ) ) );
+	}
+
+	/**
+	 * @testdox 'resolve_upload_file_path' never uses a stream URL as-is: probing user-supplied streams (e.g. phar://) with is_readable() could trigger wrapper side effects.
+	 */
+	public function test_resolve_upload_file_path_does_not_use_stream_urls_as_is(): void {
+		foreach ( array( 'phar://evil.phar/payload.csv', 'PHAR://evil.phar/payload.csv' ) as $stream_url ) {
+			$this->assertSame( ABSPATH . $stream_url, FilesystemUtil::resolve_upload_file_path( $stream_url ) );
+		}
+	}
+
+	/**
+	 * @testdox 'resolve_upload_file_path' returns the ABSPATH fallback for paths containing NUL bytes without throwing.
+	 */
+	public function test_resolve_upload_file_path_nul_byte(): void {
+		$file_path = "sample.csv\0.txt";
+
+		$this->assertSame( ABSPATH . $file_path, FilesystemUtil::resolve_upload_file_path( $file_path ) );
+	}
+
+	/**
+	 * @testdox 'resolve_upload_file_path' skips a readable site-root file outside ABSPATH and uploads and returns the ABSPATH fallback, which 'validate_upload_file_path' rejects.
+	 */
+	public function test_resolve_upload_file_path_skips_site_root_file_outside_allowed_locations(): void {
+		$site_root = sys_get_temp_dir() . '/wc-fsutil-siteroot-' . wp_generate_uuid4();
+		$path      = $this->make_temp_file( $site_root );
+		$file_name = basename( $path );
+
+		Constants::set_constant( 'WP_CONTENT_DIR', $site_root . '/wp-content' );
+
+		$resolved = FilesystemUtil::resolve_upload_file_path( $file_name );
+		$this->assertSame( ABSPATH . $file_name, $resolved );
+
+		$this->expectException( \Exception::class );
+		FilesystemUtil::validate_upload_file_path( $resolved );
+	}
+
+	/**
+	 * @testdox 'resolve_upload_file_path' prefers the uploads candidate when a readable site-root file outside the allowlist shadows it.
+	 */
+	public function test_resolve_upload_file_path_prefers_allowed_uploads_over_site_root_shadow(): void {
+		$upload_dir   = wp_get_upload_dir();
+		$uploads_file = $this->make_temp_file( $upload_dir['basedir'] );
+		$file_name    = basename( $uploads_file );
+		$site_root    = sys_get_temp_dir() . '/wc-fsutil-siteroot-' . wp_generate_uuid4();
+
+		wp_mkdir_p( $site_root );
+		$this->temp_dirs[] = $site_root;
+		$site_root_file    = $site_root . '/' . $file_name;
+		FilesystemUtil::get_wp_filesystem_direct()->put_contents( $site_root_file, '' );
+		$this->temp_files[] = $site_root_file;
+
+		Constants::set_constant( 'WP_CONTENT_DIR', $site_root . '/wp-content' );
+
+		$this->assertSame(
+			$uploads_file,
+			FilesystemUtil::resolve_upload_file_path( $file_name ),
+			'A readable site-root candidate outside the allowlist must not shadow the valid uploads candidate.'
+		);
+	}
+
+	/**
+	 * @testdox 'resolve_upload_file_path' prefers the ABSPATH candidate when the same relative path is readable in multiple bases.
+	 */
+	public function test_resolve_upload_file_path_prefers_abspath_over_uploads(): void {
+		$upload_dir   = wp_get_upload_dir();
+		$uploads_file = $this->make_temp_file( $upload_dir['basedir'] );
+		$file_name    = basename( $uploads_file );
+		$abspath_file = ABSPATH . $file_name;
+
+		FilesystemUtil::get_wp_filesystem_direct()->put_contents( $abspath_file, '' );
+		$this->temp_files[] = $abspath_file;
+
+		$this->assertSame(
+			$abspath_file,
+			FilesystemUtil::resolve_upload_file_path( $file_name ),
+			'The ABSPATH candidate must take precedence over the uploads-directory candidate.'
+		);
+	}
+
+	/**
+	 * @testdox 'resolve_upload_file_path' falls back to the ABSPATH-relative path when nothing is readable.
+	 */
+	public function test_resolve_upload_file_path_fallback_when_unreadable(): void {
+		$file_path = 'definitely-does-not-exist-' . wp_generate_uuid4() . '.csv';
+
+		$this->assertSame( ABSPATH . $file_path, FilesystemUtil::resolve_upload_file_path( $file_path ) );
+	}
+
 	/**
 	 * @testdox 'file_is_in_directory' keeps stream-wrapper (e.g. s3://) containment intact for upload paths.
 	 *