Commit 6abb71acf1e for woocommerce
commit 6abb71acf1e78357ea34281fa43d70120d012bf4
Author: Ján Mikláš <neosinner@gmail.com>
Date: Thu Nov 12 12:09:27 2026 +0100
Apply remote logger sanitization to tags and extra context (#70868)
* Apply remote logger sanitization to tags and extra context
* Add changelog entry for remote logger context sanitization
* Limit how much remote logger context sanitization recurses
sanitize_array() recursed with no limit, so a log context containing an
array that references itself exhausted memory. That fatal can't be caught
by handle(), while before this branch the same input failed JSON encoding
and was caught. Arrays nested deeper than 10 levels are now replaced with
a placeholder.
A depth limit alone still lets an array with several references to itself
grow exponentially (four references over ten levels exhaust a 128M memory
limit), so sanitization also stops after 1000 values in total and replaces
the rest with a placeholder.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NM9YXvK3fBCDSofA7K27BK
* Document remote logger filter changes from context sanitization
Note in the woocommerce_remote_logger_sanitized_content docblock that the
filter now also runs for caller tags and extra values, and in the
woocommerce_remote_logger_formatted_log_data docblock that tags and extra
arrive sanitized, with the depth and item limits. Fix the sanitize_array()
docblock: values past the item limit are dropped, not each replaced.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NM9YXvK3fBCDSofA7K27BK
* Assert the exact depth limit in the self-referencing context test
The test only checked that a placeholder appears somewhere, so it would
still pass if the depth limit dropped from 10 to 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NM9YXuS8GxMr6enTxXW45y
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-70866-remote-logger-sanitize-extra-context b/plugins/woocommerce/changelog/fix-70866-remote-logger-sanitize-extra-context
new file mode 100644
index 00000000000..23d03ec89b2
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-70866-remote-logger-sanitize-extra-context
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Apply remote logging sanitization to tags and additional context, not only to the message and backtrace.
diff --git a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php
index f849f8a00c4..b0a6010602b 100644
--- a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php
+++ b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php
@@ -34,6 +34,16 @@ class RemoteLogger extends \WC_Log_Handler {
*/
private const THIRD_PARTY_CHECK_SOURCES = array( 'fatal-errors', 'store-api' );
+ /**
+ * How many levels of nested arrays in the log context are sanitized. Deeper arrays are replaced with a placeholder.
+ */
+ private const MAX_SANITIZE_DEPTH = 10;
+
+ /**
+ * How many values in the log context are sanitized in total. Values past the limit are replaced with a placeholder.
+ */
+ private const MAX_SANITIZE_ITEMS = 1000;
+
/**
* Handle a log entry.
*
@@ -106,7 +116,7 @@ class RemoteLogger extends \WC_Log_Handler {
}
if ( isset( $context['tags'] ) && is_array( $context['tags'] ) ) {
- $log_data['tags'] = array_merge( $log_data['tags'], $context['tags'] );
+ $log_data['tags'] = array_merge( $log_data['tags'], $this->sanitize_array( $context['tags'] ) );
unset( $context['tags'] );
}
@@ -120,12 +130,14 @@ class RemoteLogger extends \WC_Log_Handler {
unset( $context['remote-logging'] );
// Merge the extra attributes with the remaining context since we can't send arbitrary fields to Logstash.
- $log_data['extra'] = array_merge( $extra_attrs, $context );
+ $log_data['extra'] = $this->sanitize_array( array_merge( $extra_attrs, $context ) );
/**
* Filters the formatted log data before sending it to the remote logging service.
* Returning a non-array value will prevent the log from being sent.
*
+ * Since 11.4.0, string values in `tags` and `extra` are already sanitized when this filter runs. In `extra`, arrays nested more than 10 levels deep are replaced with `[max_depth_reached]`, and after the first 1000 values a `[max_items_reached]` placeholder is added and the rest are dropped.
+ *
* @since 9.2.0
*
* @param array $log_data The formatted log data.
@@ -437,6 +449,8 @@ class RemoteLogger extends \WC_Log_Handler {
/**
* Filter the sanitized log content before it's sent to the remote logging service.
*
+ * Runs for the log message and backtrace and, since 11.4.0, for each string value in the caller's `tags` and in `extra`, including nested arrays. It does not run for the default tags.
+ *
* @since 9.5.0
*
* @param string $sanitized The sanitized content.
@@ -445,6 +459,39 @@ class RemoteLogger extends \WC_Log_Handler {
return apply_filters( 'woocommerce_remote_logger_sanitized_content', $sanitized, $content );
}
+ /**
+ * Sanitize the string values of an array, including nested arrays.
+ *
+ * Returns a new array, so values the caller passed by reference are not changed.
+ * Arrays nested deeper than MAX_SANITIZE_DEPTH are replaced with a placeholder. After MAX_SANITIZE_ITEMS values in total, a placeholder is added and the remaining values are dropped. This way an array that references itself can't recurse forever or exhaust memory.
+ *
+ * @param array $data The array to sanitize.
+ * @param int $depth The nesting level of $data.
+ * @param int $count How many values have been sanitized so far, shared across the recursion.
+ *
+ * @return array The sanitized array.
+ */
+ private function sanitize_array( array $data, int $depth = 0, int &$count = 0 ): array {
+ $sanitized = array();
+
+ foreach ( $data as $key => $value ) {
+ if ( ++$count > self::MAX_SANITIZE_ITEMS ) {
+ $sanitized[ $key ] = '[max_items_reached]';
+ break;
+ }
+
+ if ( ! is_array( $value ) ) {
+ $sanitized[ $key ] = $this->sanitize( $value );
+ } elseif ( $depth < self::MAX_SANITIZE_DEPTH ) {
+ $sanitized[ $key ] = $this->sanitize_array( $value, $depth + 1, $count );
+ } else {
+ $sanitized[ $key ] = '[max_depth_reached]';
+ }
+ }
+
+ return $sanitized;
+ }
+
/**
* Normalize file paths by replacing absolute paths with relative ones.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php b/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php
index 880b2c3634e..e4d122bb861 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php
@@ -262,6 +262,93 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging {
}
+ /**
+ * @testdox get_formatted_log sanitizes string values in extra, nested arrays and remaining context.
+ */
+ public function test_get_formatted_log_sanitizes_extra_context(): void {
+ $formatted_log = $this->sut->get_formatted_log(
+ 'error',
+ 'Test error message',
+ array(
+ 'extra' => array(
+ 'email' => 'Contact john@example.com',
+ 'nested' => array( 'file' => ABSPATH . 'wp-content/file.php' ),
+ 'count' => 5,
+ ),
+ 'customer' => 'jane@example.com',
+ )
+ );
+
+ $extra = $formatted_log['extra'];
+ $this->assertSame( 'Contact [redacted_email]', $extra['email'], 'Extra values should be sanitized' );
+ $this->assertSame( './wp-content/file.php', $extra['nested']['file'], 'Nested extra values should be sanitized' );
+ $this->assertSame( 5, $extra['count'], 'Non-string values should be left unchanged' );
+ $this->assertSame( '[redacted_email]', $extra['customer'], 'Remaining context values should be sanitized' );
+ }
+
+ /**
+ * @testdox get_formatted_log does not change values the caller passed by reference.
+ */
+ public function test_get_formatted_log_keeps_referenced_context_values(): void {
+ $email = 'john@example.com';
+ $context = array( 'extra' => array( 'nested' => array( 'email' => &$email ) ) );
+
+ $formatted_log = $this->sut->get_formatted_log( 'error', 'Test error message', $context );
+
+ $this->assertSame( '[redacted_email]', $formatted_log['extra']['nested']['email'], 'The logged value should be sanitized' );
+ $this->assertSame( 'john@example.com', $email, 'The caller variable should be left unchanged' );
+ }
+
+ /**
+ * @testdox get_formatted_log stops sanitizing at the maximum depth when the context references itself.
+ */
+ public function test_get_formatted_log_handles_self_referencing_context(): void {
+ $extra = array( 'email' => 'john@example.com' );
+ $extra['self'] = &$extra;
+
+ $formatted_log = $this->sut->get_formatted_log( 'error', 'Test error message', array( 'extra' => $extra ) );
+
+ $this->assertSame( '[redacted_email]', $formatted_log['extra']['self']['email'], 'Values within the depth limit should be sanitized' );
+
+ $level = $formatted_log['extra'];
+ $depth = 0;
+ while ( is_array( $level['self'] ) ) {
+ $level = $level['self'];
+ ++$depth;
+ }
+ $this->assertSame( 10, $depth, 'Nested arrays should be kept up to the depth limit' );
+ $this->assertSame( '[max_depth_reached]', $level['self'], 'Arrays past the depth limit should be replaced with a placeholder' );
+ }
+
+ /**
+ * @testdox get_formatted_log stops sanitizing at the maximum item count when the context references itself many times.
+ */
+ public function test_get_formatted_log_handles_context_with_many_self_references(): void {
+ $extra = array( 'email' => 'john@example.com' );
+ for ( $i = 1; $i <= 4; $i++ ) {
+ $extra[ 'self' . $i ] = &$extra;
+ }
+
+ $formatted_log = $this->sut->get_formatted_log( 'error', 'Test error message', array( 'extra' => $extra ) );
+
+ $this->assertSame( '[redacted_email]', $formatted_log['extra']['email'], 'Values within the limits should be sanitized' );
+
+ $encoded = wp_json_encode( $formatted_log['extra'] );
+ $this->assertIsString( $encoded, 'The sanitized context should be JSON encodable' );
+ $this->assertStringContainsString( '[max_items_reached]', $encoded, 'Values past the item limit should be replaced with a placeholder' );
+ }
+
+ /**
+ * @testdox get_formatted_log sanitizes tags from the context but not the default tags.
+ */
+ public function test_get_formatted_log_sanitizes_context_tags_only(): void {
+ add_filter( 'woocommerce_remote_logger_sanitized_content', 'strtoupper' );
+
+ $formatted_log = $this->sut->get_formatted_log( 'error', 'Test error message', array( 'tags' => array( 'john@example.com' ) ) );
+
+ $this->assertSame( array( 'woocommerce', 'php', '[REDACTED_EMAIL]' ), $formatted_log['tags'] );
+ }
+
/**
* @testdox get_formatted_log method correctly sanitizes request URI
*/