Commit 752c2af961f for woocommerce
commit 752c2af961fbb31c094b52e66709f2f41859856f
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date: Tue Sep 22 13:46:23 2026 +0200
Sanitize customer names in email Reply-to headers and Store API addresses (#68802)
* Sanitize names used in email Reply-to headers
Admin order emails built the Reply-to header from the billing name as
stored on the order. Names saved through the Store API can contain line
breaks and commas, which wp_mail treats as header and address
separators, so the header could break apart.
The billing name is now run through sanitize_text_field with commas
removed, the email through sanitize_email, and the header is skipped
when either ends up empty. The store Reply-to name gets line breaks
and commas removed too, without changing the public getters.
* Sanitize Store API address text fields like classic checkout
Classic checkout runs every posted address field through wc_clean, but
the Store API address schema only applied the REST schema sanitizer
and wp_kses. Names and other free-text address fields could be saved
with line breaks and other characters the classic flow removes.
Core address fields now also go through sanitize_text_field, so both
checkouts store the same values. Additional checkout fields keep using
their own field sanitizer and are not changed.
* Remove commas from withdrawal request Reply-to name
The withdrawal request email puts the name from the customer's form
into the Reply-to header. wp_mail splits Reply-to on commas, so a name
like "Smith, Jr." broke into two entries. Commas are now removed, the
same way the admin order emails handle billing names.
* Keep Store API billing email out of text field sanitizing
The billing address schema already cleans the email with
sanitize_email. Running sanitize_text_field first removed percent
sequences, which changed valid addresses such as user%41b@example.com.
* Check Reply-to names against empty strings, not falsy values
A billing name of "0" was dropped from the admin order Reply-to header
because PHP treats "0" as false. The withdrawal request email checked
the name before removing commas, so a name of only commas gave an
empty display name. Both now check the cleaned value against ''.
* Add changelog entry for Reply-to and Store API address sanitizing
* Use string functions instead of regexes in Reply-to header tests
The repo guidelines prefer plain string operations over regular
expressions. The helpers that split headers and pick out the Reply-to
line now use str_replace, explode, strpos and substr. The assertions
are unchanged, and the tests still fail when the header fixes are
reverted.
* Drop redundant option cleanup from Reply-to header tests
WP_UnitTestCase runs each test in a transaction that is rolled back
afterwards and flushes the object cache, so option changes never reach
later tests. The manual delete_option calls were not needed and read as
if they wiped settings that existed before the test.
* Only build the admin order Reply-to header for order objects
The admin order branch of get_headers() called billing getters on any
truthy $this->object. It now requires a WC_Order, so another value
there no longer triggers a fatal error, and the three PHPStan baseline
entries about calling those getters on object|true are gone. Tests now
mock WC_Order and cover a non-order object.
* Share Reply-to name cleanup between email classes
The admin order emails and the withdrawal request email cleaned the
Reply-to name with the same inline expression. Both now call a
protected WC_Email::sanitize_reply_to_name() helper. It has no type
declarations, so extensions that subclass WC_Email with a method of the
same name keep loading.
* Move Reply-to name cleanup to an internal email helper
The shared cleanup was a protected method on WC_Email. Extensions
subclass WC_Email, and one that already has a private, static or typed
method with the same name would fail to load. The cleanup now lives in
the internal EmailHeaders class, which nothing inherits from.
* Remove line breaks after the text field filter runs
sanitize_text_field() ends with its own filter, so a callback can hand
back a value that still holds line breaks. The Reply-to name helper now
removes them after that call and trims the result.
* Simplify Reply-to header test assertions
Both test files carried a copy of the same helper that cut the Reply-to
line out of the headers. The tests now assert on the expected line with
assertStringContainsString, and the withdrawal name cases share one
test with a data set each.
* Check the address and clean every Reply-to name the same way
The admin order header accepted any non-empty result from
sanitize_email, which ends in a filter like sanitize_text_field does,
so it now also requires is_email. The store reply-to and from-name
paths had their own weaker cleanup that kept tag-like text; they now
use the shared helper, guard on the cleaned name, and read the from
address and name once.
diff --git a/plugins/woocommerce/changelog/fix-WOO6-158-sanitize-email-reply-to-names b/plugins/woocommerce/changelog/fix-WOO6-158-sanitize-email-reply-to-names
new file mode 100644
index 00000000000..2709b9ab4bc
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-WOO6-158-sanitize-email-reply-to-names
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep customer names used in email Reply-to headers on a single line and sanitize Store API address fields like classic checkout.
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-order-withdrawal-requested.php b/plugins/woocommerce/includes/emails/class-wc-email-order-withdrawal-requested.php
index 769aee16b06..41e2ea64d4c 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-order-withdrawal-requested.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-order-withdrawal-requested.php
@@ -5,6 +5,7 @@
* @package WooCommerce\Emails
*/
+use Automattic\WooCommerce\Internal\Email\EmailHeaders;
use Automattic\WooCommerce\Internal\OrderWithdrawal\Emails\OrderWithdrawalEmailDataFormatter;
use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormProcessor;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
@@ -158,8 +159,10 @@ if ( ! class_exists( 'WC_Email_Order_Withdrawal_Requested', false ) ) :
$name = $this->formatter->get_customer_name( $this->withdrawal_data );
$email = $this->withdrawal_data[ OrderWithdrawalFormProcessor::FIELD_EMAIL ] ?? '';
- if ( '' !== $name && is_email( $email ) ) {
- $headers .= 'Reply-to: ' . sanitize_text_field( $name ) . ' <' . sanitize_email( $email ) . ">\r\n";
+ $cleaned_name = EmailHeaders::sanitize_reply_to_name( $name );
+
+ if ( '' !== $cleaned_name && is_email( $email ) ) {
+ $headers .= 'Reply-to: ' . $cleaned_name . ' <' . sanitize_email( $email ) . ">\r\n";
}
if ( FeaturesUtil::feature_is_enabled( 'email_improvements' ) ) {
diff --git a/plugins/woocommerce/includes/emails/class-wc-email.php b/plugins/woocommerce/includes/emails/class-wc-email.php
index 7c027ab5e15..8cf6036cf50 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email.php
@@ -6,6 +6,7 @@
*/
use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
+use Automattic\WooCommerce\Internal\Email\EmailHeaders;
use Automattic\WooCommerce\Internal\EmailEditor\BlockEmailRenderer;
use Automattic\WooCommerce\Internal\EmailEditor\TransactionalEmailPersonalizer;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
@@ -687,8 +688,13 @@ class WC_Email extends WC_Settings_API {
// For order notification emails sent to admin, always use customer's billing email as reply-to.
if ( in_array( $this->id, array( 'new_order', 'cancelled_order', 'failed_order' ), true ) ) {
- if ( $this->object && $this->object->get_billing_email() && ( $this->object->get_billing_first_name() || $this->object->get_billing_last_name() ) ) {
- $header .= 'Reply-to: ' . $this->object->get_billing_first_name() . ' ' . $this->object->get_billing_last_name() . ' <' . $this->object->get_billing_email() . ">\r\n";
+ if ( $this->object instanceof WC_Order ) {
+ $reply_to_name = EmailHeaders::sanitize_reply_to_name( $this->object->get_billing_first_name() . ' ' . $this->object->get_billing_last_name() );
+ $reply_to_email = sanitize_email( $this->object->get_billing_email() );
+
+ if ( '' !== $reply_to_name && is_email( $reply_to_email ) ) {
+ $header .= 'Reply-to: ' . $reply_to_name . ' <' . $reply_to_email . ">\r\n";
+ }
}
} else {
// Check if custom reply-to is enabled and configured for non-admin notification emails.
@@ -697,10 +703,20 @@ class WC_Email extends WC_Settings_API {
$reply_to_name = $this->get_reply_to_name();
if ( $reply_to_enabled && ! empty( $reply_to_address ) && is_email( $reply_to_address ) ) {
- $reply_to_name = ! empty( $reply_to_name ) ? $reply_to_name : $this->get_from_name();
- $header .= 'Reply-to: ' . $reply_to_name . ' <' . $reply_to_address . ">\r\n";
- } elseif ( $this->get_from_address() && $this->get_from_name() ) {
- $header .= 'Reply-to: ' . $this->get_from_name() . ' <' . $this->get_from_address() . ">\r\n";
+ $reply_to_name = EmailHeaders::sanitize_reply_to_name( $reply_to_name );
+
+ if ( '' === $reply_to_name ) {
+ $reply_to_name = EmailHeaders::sanitize_reply_to_name( $this->get_from_name() );
+ }
+
+ $header .= 'Reply-to: ' . $reply_to_name . ' <' . $reply_to_address . ">\r\n";
+ } else {
+ $from_address = $this->get_from_address();
+ $from_name = EmailHeaders::sanitize_reply_to_name( $this->get_from_name() );
+
+ if ( $from_address && '' !== $from_name ) {
+ $header .= 'Reply-to: ' . $from_name . ' <' . $from_address . ">\r\n";
+ }
}
}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index a3f80491f94..d108826542f 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -18751,24 +18751,6 @@ parameters:
count: 1
path: includes/emails/class-wc-email.php
- -
- message: '#^Cannot call method get_billing_email\(\) on object\|true\.$#'
- identifier: method.nonObject
- count: 2
- path: includes/emails/class-wc-email.php
-
- -
- message: '#^Cannot call method get_billing_first_name\(\) on object\|true\.$#'
- identifier: method.nonObject
- count: 2
- path: includes/emails/class-wc-email.php
-
- -
- message: '#^Cannot call method get_billing_last_name\(\) on object\|true\.$#'
- identifier: method.nonObject
- count: 2
- path: includes/emails/class-wc-email.php
-
-
message: '#^Method WC_Email\:\:admin_actions\(\) has no return type specified\.$#'
identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/Email/EmailHeaders.php b/plugins/woocommerce/src/Internal/Email/EmailHeaders.php
new file mode 100644
index 00000000000..864d4797d86
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Email/EmailHeaders.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * EmailHeaders class file
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Email;
+
+/**
+ * Helper class for building safe email headers.
+ *
+ * @internal Just for internal use.
+ * @since 11.3.0
+ */
+final class EmailHeaders {
+
+ /**
+ * Clean a customer-provided name for use in a Reply-to header.
+ *
+ * Line breaks are collapsed to spaces and commas removed, since wp_mail()
+ * splits Reply-to values on commas. The line break removal runs again
+ * after sanitize_text_field(), since its 'sanitize_text_field' filter
+ * runs last and could reintroduce them.
+ *
+ * @since 11.3.0
+ * @param string $name Name to clean.
+ * @return string
+ */
+ public static function sanitize_reply_to_name( string $name ): string {
+ $name = str_replace( array( "\r", "\n" ), ' ', sanitize_text_field( $name ) );
+ return trim( str_replace( ',', '', $name ) );
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
index 8d0063b5bfb..4b1595cd17a 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
@@ -138,6 +138,11 @@ abstract class AbstractAddressSchema extends AbstractSchema {
break;
default:
$carry[ $key ] = rest_sanitize_value_from_schema( $address[ $key ], $schema[ $key ], $key );
+ // Additional fields are sanitized separately below, via sanitize_field().
+ // Email is excluded because its own schema sanitizer already applies sanitize_email().
+ if ( 'email' !== $key && ! $this->additional_fields_controller->is_field( $key ) && is_string( $carry[ $key ] ) ) {
+ $carry[ $key ] = sanitize_text_field( $carry[ $key ] );
+ }
break;
}
if ( $this->additional_fields_controller->is_field( $key ) ) {
diff --git a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-order-withdrawal-requested-test.php b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-order-withdrawal-requested-test.php
new file mode 100644
index 00000000000..b65bca364b8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-order-withdrawal-requested-test.php
@@ -0,0 +1,66 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormProcessor;
+
+/**
+ * WC_Email_Order_Withdrawal_Requested::get_headers() Reply-to tests.
+ *
+ * @covers WC_Email_Order_Withdrawal_Requested::get_headers
+ */
+class WC_Email_Order_Withdrawal_Requested_Test extends \WC_Unit_Test_Case {
+
+ /**
+ * Load up the email class since it isn't loaded by default.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $bootstrap = \WC_Unit_Tests_Bootstrap::instance();
+ require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email.php';
+ require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email-order-withdrawal-requested.php';
+ }
+
+ /**
+ * Build a withdrawal email instance with the given submitter name and email.
+ *
+ * @param string $first_name Submitter first name.
+ * @param string $last_name Submitter last name.
+ * @param string $email Submitter email.
+ * @return WC_Email_Order_Withdrawal_Requested
+ */
+ private function make_email( string $first_name, string $last_name, string $email ): WC_Email_Order_Withdrawal_Requested {
+ $wc_email = new WC_Email_Order_Withdrawal_Requested();
+ $wc_email->withdrawal_data = array(
+ OrderWithdrawalFormProcessor::FIELD_FIRST_NAME => $first_name,
+ OrderWithdrawalFormProcessor::FIELD_LAST_NAME => $last_name,
+ OrderWithdrawalFormProcessor::FIELD_EMAIL => $email,
+ );
+
+ return $wc_email;
+ }
+
+ /**
+ * @testWith ["Smith,", "Jr.", "Reply-to: Smith Jr. <guest@example.com>\r\n"]
+ * ["x@evil.test,", "Bob", "Reply-to: x@evil.test Bob <guest@example.com>\r\n"]
+ * ["Jane", "Doe", "Reply-to: Jane Doe <guest@example.com>\r\n"]
+ *
+ * @param string $first_name Submitter first name.
+ * @param string $last_name Submitter last name.
+ * @param string $reply_to_line Expected Reply-to header line.
+ */
+ public function test_submitter_name_reply_to_line( string $first_name, string $last_name, string $reply_to_line ): void {
+ $email = $this->make_email( $first_name, $last_name, 'guest@example.com' );
+
+ $this->assertStringContainsString( $reply_to_line, $email->get_headers() );
+ }
+
+ /**
+ * @testdox A name made only of a comma produces no Reply-to line.
+ */
+ public function test_comma_only_submitter_name_produces_no_reply_to(): void {
+ $email = $this->make_email( ',', '', 'guest@example.com' );
+
+ $this->assertStringNotContainsString( 'Reply-to:', $email->get_headers() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-reply-to-header-test.php b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-reply-to-header-test.php
new file mode 100644
index 00000000000..0d7ae0bdaac
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-reply-to-header-test.php
@@ -0,0 +1,323 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * WC_Email::get_headers() Reply-to tests.
+ *
+ * @covers WC_Email::get_headers
+ */
+class WC_Email_Reply_To_Header_Test extends \WC_Unit_Test_Case {
+
+ /**
+ * Load up the email classes since they aren't loaded by default, and
+ * make sure the legacy header format is used regardless of the
+ * environment's feature flag defaults.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ update_option( 'woocommerce_feature_email_improvements_enabled', 'no' );
+
+ $bootstrap = \WC_Unit_Tests_Bootstrap::instance();
+ require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email.php';
+ require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email-new-order.php';
+ require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email-cancelled-order.php';
+ require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email-failed-order.php';
+ require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email-customer-processing-order.php';
+ }
+
+ /**
+ * Split header text into individual lines, so newline variants used to
+ * smuggle extra headers are all normalized the same way.
+ *
+ * @param string $headers Raw header string.
+ * @return array
+ */
+ private function split_headers( string $headers ): array {
+ $normalized = str_replace( array( "\r\n", "\r" ), array( "\n", "\n" ), $headers );
+ return explode( "\n", $normalized );
+ }
+
+ /**
+ * Data provider of admin order emails and line-break separators used to
+ * try to inject an extra header via the billing name.
+ *
+ * @return array
+ */
+ public function admin_order_email_and_separator_provider(): array {
+ $emails = array(
+ 'new_order' => WC_Email_New_Order::class,
+ 'cancelled_order' => WC_Email_Cancelled_Order::class,
+ 'failed_order' => WC_Email_Failed_Order::class,
+ );
+
+ $cases = array();
+ foreach ( $emails as $email_id => $email_class ) {
+ // "\r" alone is a regression-only case: wp_mail splits headers on "\n", so this could pass even without the fix.
+ foreach ( array( "\r\n", "\n", "\r" ) as $separator_label => $separator ) {
+ $cases[ "$email_id, separator index $separator_label" ] = array( $email_class, $separator );
+ }
+ }
+
+ return $cases;
+ }
+
+ /**
+ * @testdox A line break in the billing name does not add an extra header line.
+ * @dataProvider admin_order_email_and_separator_provider
+ *
+ * @param string $email_class Admin order email class to instantiate.
+ * @param string $separator Line-break variant used inside the billing name.
+ */
+ public function test_admin_order_email_billing_name_line_break_does_not_inject_header( string $email_class, string $separator ): void {
+ $order = WC_Helper_Order::create_order();
+ $order->set_billing_first_name( "Foo{$separator}Bcc: attacker@example.test{$separator}X-Injected:" );
+ $order->set_billing_last_name( 'Bar' );
+ $order->set_billing_email( 'guest@example.com' );
+ $order->save();
+
+ $email = new $email_class();
+ $email->object = $order;
+
+ $headers = $email->get_headers();
+
+ $this->assertStringContainsString(
+ "Reply-to: Foo Bcc: attacker@example.test X-Injected: Bar <guest@example.com>\r\n",
+ $headers
+ );
+
+ $lines = $this->split_headers( $headers );
+ $reply_to_lines = array_filter( $lines, static fn( $line ) => 0 === strpos( $line, 'Reply-to:' ) );
+ $non_reply_to_lines = array_filter( $lines, static fn( $line ) => 0 !== strpos( $line, 'Reply-to:' ) );
+
+ $this->assertCount( 1, $reply_to_lines, 'Exactly one Reply-to line should be present' );
+ foreach ( $non_reply_to_lines as $line ) {
+ $this->assertStringNotContainsString( 'attacker@example.test', $line, 'No header other than Reply-to should reference the injected address' );
+ }
+ }
+
+ /**
+ * @testdox A comma in the billing name is removed since wp_mail splits Reply-to on commas.
+ */
+ public function test_admin_order_email_billing_name_comma_is_removed(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->set_billing_first_name( 'Smith,' );
+ $order->set_billing_last_name( 'Jr.' );
+ $order->set_billing_email( 'guest@example.com' );
+ $order->save();
+
+ $email = new WC_Email_New_Order();
+ $email->object = $order;
+
+ $this->assertStringContainsString( "Reply-to: Smith Jr. <guest@example.com>\r\n", $email->get_headers() );
+ }
+
+ /**
+ * @testdox Legitimate names with accents and apostrophes are unchanged.
+ */
+ public function test_admin_order_email_billing_name_with_accents_and_apostrophe_is_unchanged(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->set_billing_first_name( 'María' );
+ $order->set_billing_last_name( "O'Brien" );
+ $order->set_billing_email( 'guest@example.com' );
+ $order->save();
+
+ $email = new WC_Email_New_Order();
+ $email->object = $order;
+
+ $this->assertStringContainsString( "Reply-to: María O'Brien <guest@example.com>\r\n", $email->get_headers() );
+ }
+
+ /**
+ * @testdox No Reply-to line is added when the billing email is invalid.
+ */
+ public function test_admin_order_email_invalid_billing_email_produces_no_reply_to(): void {
+ // WC_Order::set_billing_email() throws on an invalid address, so the
+ // order getters are stubbed on a mock instead of going through a real order.
+ $order = $this->createMock( WC_Order::class );
+ $order->method( 'get_billing_first_name' )->willReturn( 'Foo' );
+ $order->method( 'get_billing_last_name' )->willReturn( 'Bar' );
+ $order->method( 'get_billing_email' )->willReturn( 'not-an-email' );
+
+ $email = new WC_Email_New_Order();
+ $email->object = $order;
+
+ $this->assertStringNotContainsString( 'Reply-to:', $email->get_headers() );
+ }
+
+ /**
+ * @testdox No Reply-to line is added when a "sanitize_email" filter hands back a value that fails is_email().
+ */
+ public function test_admin_order_email_sanitize_email_filter_returning_invalid_address_produces_no_reply_to(): void {
+ // sanitize_email() ends with an apply_filters( 'sanitize_email', ... ) call, so a callback can
+ // hand back a non-empty value that does not pass is_email().
+ $filter = static fn() => 'not-an-email';
+ add_filter( 'sanitize_email', $filter );
+
+ $order = $this->createMock( WC_Order::class );
+ $order->method( 'get_billing_first_name' )->willReturn( 'Foo' );
+ $order->method( 'get_billing_last_name' )->willReturn( 'Bar' );
+ $order->method( 'get_billing_email' )->willReturn( 'guest@example.com' );
+
+ $email = new WC_Email_New_Order();
+ $email->object = $order;
+
+ try {
+ $headers = $email->get_headers();
+ } finally {
+ remove_filter( 'sanitize_email', $filter );
+ }
+
+ $this->assertStringNotContainsString( 'Reply-to:', $headers );
+ }
+
+ /**
+ * @testdox No Reply-to line is added when the billing name has no visible characters.
+ * @testWith ["\r\n"]
+ * [" "]
+ *
+ * @param string $name Billing first name made up only of whitespace/line breaks.
+ */
+ public function test_admin_order_email_blank_billing_name_produces_no_reply_to( string $name ): void {
+ $order = $this->createMock( WC_Order::class );
+ $order->method( 'get_billing_first_name' )->willReturn( $name );
+ $order->method( 'get_billing_last_name' )->willReturn( '' );
+ $order->method( 'get_billing_email' )->willReturn( 'guest@example.com' );
+
+ $email = new WC_Email_New_Order();
+ $email->object = $order;
+
+ $this->assertStringNotContainsString( 'Reply-to:', $email->get_headers() );
+ }
+
+ /**
+ * @testdox A billing name of "0" is not treated as empty.
+ */
+ public function test_admin_order_email_billing_name_of_zero_is_kept(): void {
+ $order = $this->createMock( WC_Order::class );
+ $order->method( 'get_billing_first_name' )->willReturn( '0' );
+ $order->method( 'get_billing_last_name' )->willReturn( '' );
+ $order->method( 'get_billing_email' )->willReturn( 'guest@example.com' );
+
+ $email = new WC_Email_New_Order();
+ $email->object = $order;
+
+ $this->assertStringContainsString( "Reply-to: 0 <guest@example.com>\r\n", $email->get_headers() );
+ }
+
+ /**
+ * @testdox No Reply-to line is added when the object is not a WC_Order.
+ */
+ public function test_admin_order_email_non_order_object_produces_no_reply_to(): void {
+ $order = $this->getMockBuilder( 'stdClass' )
+ ->addMethods( array( 'get_billing_first_name', 'get_billing_last_name', 'get_billing_email' ) )
+ ->getMock();
+ $order->method( 'get_billing_first_name' )->willReturn( 'Foo' );
+ $order->method( 'get_billing_last_name' )->willReturn( 'Bar' );
+ $order->method( 'get_billing_email' )->willReturn( 'guest@example.com' );
+
+ $email = new WC_Email_New_Order();
+ $email->object = $order;
+
+ $this->assertStringNotContainsString( 'Reply-to:', $email->get_headers() );
+ }
+
+ /**
+ * @testdox Custom reply-to name falling back to a filtered from-name stays on a single line.
+ */
+ public function test_custom_reply_to_falls_back_to_from_name_without_injecting_header(): void {
+ update_option( 'woocommerce_email_reply_to_enabled', 'yes' );
+ update_option( 'woocommerce_email_reply_to_address', 'reply@example.com' );
+ update_option( 'woocommerce_email_reply_to_name', '' );
+
+ $filter = static fn() => "Shop\r\nBcc: x@evil.test";
+ add_filter( 'woocommerce_email_from_name', $filter );
+
+ $email = new WC_Email_Customer_Processing_Order();
+
+ $headers = $email->get_headers();
+
+ remove_filter( 'woocommerce_email_from_name', $filter );
+
+ $reply_to_line = "Reply-to: Shop Bcc: x@evil.test <reply@example.com>\r\n";
+ $this->assertStringContainsString( $reply_to_line, $headers );
+ $this->assertStringNotContainsString( 'x@evil.test', str_replace( $reply_to_line, '', $headers ) );
+ }
+
+ /**
+ * @testdox A configured reply-to name left empty by the cleanup falls back to the from-name.
+ */
+ public function test_custom_reply_to_name_that_cleans_to_nothing_falls_back_to_from_name(): void {
+ update_option( 'woocommerce_email_reply_to_enabled', 'yes' );
+ update_option( 'woocommerce_email_reply_to_address', 'reply@example.com' );
+ update_option( 'woocommerce_email_reply_to_name', ',' );
+
+ $filter = static fn() => 'Shop';
+ add_filter( 'woocommerce_email_from_name', $filter );
+
+ $email = new WC_Email_Customer_Processing_Order();
+ $headers = $email->get_headers();
+
+ remove_filter( 'woocommerce_email_from_name', $filter );
+
+ $this->assertStringContainsString( "Reply-to: Shop <reply@example.com>\r\n", $headers );
+ }
+
+ /**
+ * @testdox From-name fallback for the reply-to header stays on a single line.
+ */
+ public function test_from_name_fallback_reply_to_does_not_inject_header(): void {
+ update_option( 'woocommerce_email_reply_to_enabled', 'no' );
+ update_option( 'woocommerce_email_from_address', 'from@address.com' );
+
+ $filter = static fn() => "Shop\r\nBcc: x@evil.test";
+ add_filter( 'woocommerce_email_from_name', $filter );
+
+ $email = new WC_Email_Customer_Processing_Order();
+
+ $headers = $email->get_headers();
+
+ remove_filter( 'woocommerce_email_from_name', $filter );
+
+ $reply_to_line = "Reply-to: Shop Bcc: x@evil.test <from@address.com>\r\n";
+ $this->assertStringContainsString( $reply_to_line, $headers );
+ $this->assertStringNotContainsString( 'x@evil.test', str_replace( $reply_to_line, '', $headers ) );
+ }
+
+ /**
+ * @testdox A comma in a filtered from-name is removed for the reply-to header.
+ */
+ public function test_from_name_fallback_reply_to_comma_is_removed(): void {
+ update_option( 'woocommerce_email_reply_to_enabled', 'no' );
+ update_option( 'woocommerce_email_from_address', 'from@address.com' );
+
+ $filter = static fn() => 'Shop, Inc.';
+ add_filter( 'woocommerce_email_from_name', $filter );
+
+ $email = new WC_Email_Customer_Processing_Order();
+ $headers = $email->get_headers();
+
+ remove_filter( 'woocommerce_email_from_name', $filter );
+
+ $this->assertStringContainsString( "Reply-to: Shop Inc. <from@address.com>\r\n", $headers );
+ }
+
+ /**
+ * @testdox An address in angle brackets inside a filtered from-name does not reach the Reply-to header.
+ */
+ public function test_from_name_fallback_reply_to_strips_embedded_address(): void {
+ update_option( 'woocommerce_email_reply_to_enabled', 'no' );
+ update_option( 'woocommerce_email_from_address', 'from@address.com' );
+
+ $filter = static fn() => 'Support <help@attacker.test>';
+ add_filter( 'woocommerce_email_from_name', $filter );
+
+ $email = new WC_Email_Customer_Processing_Order();
+ $headers = $email->get_headers();
+
+ remove_filter( 'woocommerce_email_from_name', $filter );
+
+ $this->assertStringContainsString( 'Reply-to: Support <from@address.com>' . "\r\n", $headers );
+ $this->assertStringNotContainsString( 'help@attacker.test', $headers );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
index 84ba8e8fe9b..896b7ce014d 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
@@ -33,12 +33,31 @@ class AbstractAddressSchemaTest extends WC_Unit_Test_Case {
*/
private $sut;
+ /**
+ * The id of the additional address field registered for these tests.
+ *
+ * @var string
+ */
+ private $field_id = 'plugin-namespace/delivery-notes';
+
/**
* Set up before test.
*/
public function setUp(): void {
parent::setUp();
+ add_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+
+ woocommerce_register_additional_checkout_field(
+ array(
+ 'id' => $this->field_id,
+ 'label' => 'Delivery notes',
+ 'location' => 'address',
+ 'type' => 'text',
+ 'required' => false,
+ )
+ );
+
$formatters = new Formatters();
$formatters->register( 'money', MoneyFormatter::class );
$formatters->register( 'html', HtmlFormatter::class );
@@ -49,6 +68,16 @@ class AbstractAddressSchemaTest extends WC_Unit_Test_Case {
$this->sut = $schema_controller->get( BillingAddressSchema::IDENTIFIER );
}
+ /**
+ * Tear down after test.
+ */
+ public function tearDown(): void {
+ __internal_woocommerce_blocks_deregister_checkout_field( $this->field_id );
+ remove_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+
+ parent::tearDown();
+ }
+
/**
* Build a minimal valid address with the given overrides.
*
@@ -173,4 +202,103 @@ class AbstractAddressSchemaTest extends WC_Unit_Test_Case {
'Billing email addresses should be returned as raw data, not typographic display text.'
);
}
+
+ /**
+ * @testdox Should collapse embedded line breaks in a free-text field like classic checkout does.
+ */
+ public function test_collapses_line_breaks_in_first_name(): void {
+ $address = $this->make_address( array( 'first_name' => "Foo\r\nBcc: attacker@example.test\r\nX-Injected:" ) );
+
+ $result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+ $this->assertSame( 'Foo Bcc: attacker@example.test X-Injected:', $result['first_name'] );
+ }
+
+ /**
+ * @testdox Should collapse each kind of line break to a single space.
+ * @testWith ["\n"]
+ * ["\r\n"]
+ * ["\r"]
+ *
+ * @param string $line_break The line break variant being tested.
+ */
+ public function test_collapses_line_breaks_in_free_text_fields( string $line_break ): void {
+ $address = $this->make_address(
+ array(
+ 'last_name' => 'Doe' . $line_break . 'Smith',
+ 'company' => 'Acme' . $line_break . 'Inc',
+ 'address_1' => '123 Main' . $line_break . 'Street',
+ 'address_2' => 'Suite' . $line_break . '100',
+ 'city' => 'New' . $line_break . 'York',
+ )
+ );
+
+ $result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+ $this->assertSame( 'Doe Smith', $result['last_name'] );
+ $this->assertSame( 'Acme Inc', $result['company'] );
+ $this->assertSame( '123 Main Street', $result['address_1'] );
+ $this->assertSame( 'Suite 100', $result['address_2'] );
+ $this->assertSame( 'New York', $result['city'] );
+ }
+
+ /**
+ * @testdox Should keep accented and apostrophe characters verbatim.
+ */
+ public function test_keeps_accented_and_apostrophe_names_verbatim(): void {
+ $address = $this->make_address(
+ array(
+ 'first_name' => 'María',
+ 'last_name' => "O'Brien",
+ )
+ );
+
+ $result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+ $this->assertSame( 'María', $result['first_name'] );
+ $this->assertSame( "O'Brien", $result['last_name'] );
+ }
+
+ /**
+ * @testdox Should sanitize a free-text field the same way classic checkout's wc_clean() would.
+ */
+ public function test_sanitizes_address_2_like_classic_checkout(): void {
+ $address = $this->make_address( array( 'address_2' => 'Suite%20100' ) );
+
+ $result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+ $this->assertSame( sanitize_text_field( 'Suite%20100' ), $result['address_2'] );
+ }
+
+ /**
+ * @testdox Should not run an additional address field through sanitize_text_field.
+ *
+ * Additional fields are sanitized by their own field type (sanitize_field()), not by the
+ * core-field sanitization added to the default case of the switch above. A text field's
+ * default sanitize() is a no-op, so a percent-encoded run untouched by wp_kses() is
+ * evidence the new sanitize_text_field() call was skipped for this key.
+ */
+ public function test_does_not_sanitize_additional_address_field_like_a_core_field(): void {
+ $address = $this->make_address( array( $this->field_id => 'Suite%20100' ) );
+
+ $result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+ $this->assertSame( 'Suite%20100', $result[ $this->field_id ] );
+ }
+
+ /**
+ * @testdox Should not run email through sanitize_text_field before sanitize_email.
+ *
+ * sanitize_text_field() strips percent-encoded octets like "%41" out of a string. A local
+ * part such as "user%41b" is valid per is_email() and untouched by sanitize_email(), so
+ * running it through sanitize_text_field() first would silently change the address.
+ */
+ public function test_does_not_mangle_email_with_percent_encoded_characters(): void {
+ $email = 'user%41b@example.com';
+ $address = $this->make_address( array( 'email' => $email ) );
+
+ $result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+ $this->assertSame( sanitize_email( $email ), $result['email'] );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Email/EmailHeadersTest.php b/plugins/woocommerce/tests/php/src/Internal/Email/EmailHeadersTest.php
new file mode 100644
index 00000000000..4d5f7e609d0
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Email/EmailHeadersTest.php
@@ -0,0 +1,45 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Email;
+
+use Automattic\WooCommerce\Internal\Email\EmailHeaders;
+use WC_Unit_Test_Case;
+
+/**
+ * EmailHeaders test.
+ *
+ * @covers \Automattic\WooCommerce\Internal\Email\EmailHeaders
+ */
+class EmailHeadersTest extends WC_Unit_Test_Case {
+
+ /**
+ * @testWith ["Foo\r\nBcc: a@b.test", "Foo Bcc: a@b.test"]
+ * ["Smith, Jr.", "Smith Jr."]
+ * ["María O'Brien", "María O'Brien"]
+ * [" ", ""]
+ *
+ * @param string $name Name to clean.
+ * @param string $expected Expected cleaned name.
+ */
+ public function test_sanitize_reply_to_name( string $name, string $expected ) {
+ $this->assertSame( $expected, EmailHeaders::sanitize_reply_to_name( $name ) );
+ }
+
+ /**
+ * @testdox A "sanitize_text_field" filter callback reintroducing line breaks does not leak them into the result.
+ */
+ public function test_sanitize_reply_to_name_removes_line_breaks_reintroduced_by_filter(): void {
+ $filter = static fn() => "Shop\r\nX-Test: 1";
+ add_filter( 'sanitize_text_field', $filter );
+
+ try {
+ $result = EmailHeaders::sanitize_reply_to_name( 'Shop' );
+ } finally {
+ remove_filter( 'sanitize_text_field', $filter );
+ }
+
+ $this->assertSame( 'Shop X-Test: 1', $result );
+ }
+}