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.
*