Commit e892b5fa1ab for woocommerce
commit e892b5fa1abaece653086961fc53a666ab1572a5
Author: Alba Rincón <albarin@users.noreply.github.com>
Date: Sat Jun 13 22:15:54 2026 +0200
Fix LogHandlerFileV2 emitting invalid JSON for log entry context (#65565)
* Fix LogHandlerFileV2 emitting invalid JSON for log entry context
* Add changefile(s) from automation for the following project(s): woocommerce
* Use neutral example in LogHandlerFileV2 context test
* Fix Status > Logs viewer rendering invalid JSON for log context
* Add changefile(s) from automation for the following project(s): woocommerce
* Expand LogHandlerFileV2 context test to cover more value types
* Cast encoded log context to string for esc_html()
* Remove stale PHPStan baseline entry for replaced stripslashes call
* Add guard assertions before reading log file in context JSON test
* Add viewer-side tests for log context rendering in PageController
* Hard-code expected JSON in slashed values context test case
* Document JSON round-trip type limits in context test data provider
* Cross-reference JSON flags between log writer and viewer
* Add trailing newline to changelog entry
* Add changefile(s) from automation for the following project(s): woocommerce
* Keep format_line private and drive viewer tests via reflection
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/65565-wooplug-6146-log-context-json b/plugins/woocommerce/changelog/65565-wooplug-6146-log-context-json
new file mode 100644
index 00000000000..cd99519d65e
--- /dev/null
+++ b/plugins/woocommerce/changelog/65565-wooplug-6146-log-context-json
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix the log entry context being written and displayed as invalid JSON (e.g. with backtraces or namespaced class names) in WooCommerce > Status > Logs.
\ No newline at end of file
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 8bf4aed5745..009b1629e1f 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -57382,12 +57382,6 @@ parameters:
count: 1
path: src/Internal/Admin/Logging/PageController.php
- -
- message: '#^Parameter \#1 \$str of function stripslashes expects string, string\|false given\.$#'
- identifier: argument.type
- count: 1
- path: src/Internal/Admin/Logging/PageController.php
-
-
message: '#^Parameter \#1 \$var of function count expects array\|Countable, array\<Automattic\\WooCommerce\\Internal\\Admin\\Logging\\FileV2\\File\>\|WP_Error given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/src/Internal/Admin/Logging/LogHandlerFileV2.php b/plugins/woocommerce/src/Internal/Admin/Logging/LogHandlerFileV2.php
index 58b99f99c7a..5a4ab0c6cb5 100644
--- a/plugins/woocommerce/src/Internal/Admin/Logging/LogHandlerFileV2.php
+++ b/plugins/woocommerce/src/Internal/Admin/Logging/LogHandlerFileV2.php
@@ -90,8 +90,9 @@ class LogHandlerFileV2 extends WC_Log_Handler {
unset( $context_for_entry['source'] );
if ( ! empty( $context_for_entry ) ) {
- $formatted_context = wp_json_encode( $context_for_entry, JSON_UNESCAPED_UNICODE );
- $message .= stripslashes( " CONTEXT: $formatted_context" );
+ // Keep the JSON flags in sync with the context re-encoding in PageController::format_line().
+ $formatted_context = wp_json_encode( $context_for_entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
+ $message .= " CONTEXT: $formatted_context";
}
$entry = "$time_string $level_string $message";
diff --git a/plugins/woocommerce/src/Internal/Admin/Logging/PageController.php b/plugins/woocommerce/src/Internal/Admin/Logging/PageController.php
index 3964b3b01a5..51c15d5d31f 100644
--- a/plugins/woocommerce/src/Internal/Admin/Logging/PageController.php
+++ b/plugins/woocommerce/src/Internal/Admin/Logging/PageController.php
@@ -720,18 +720,18 @@ class PageController {
$message_chunks = explode( 'CONTEXT:', $segments[2], 2 );
if ( isset( $message_chunks[1] ) ) {
try {
- $maybe_json = html_entity_decode( addslashes( trim( $message_chunks[1] ) ) );
+ $maybe_json = html_entity_decode( trim( $message_chunks[1] ), ENT_QUOTES | ENT_HTML401 );
// Decode for validation.
$context = json_decode( $maybe_json, false, 512, JSON_THROW_ON_ERROR );
- // Re-encode to make it pretty.
- $context = wp_json_encode( $context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE );
+ // Re-encode to make it pretty. Keep the JSON flags in sync with LogHandlerFileV2::format_entry().
+ $context = wp_json_encode( $context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
$message_chunks[1] = sprintf(
'<details><summary>%1$s</summary>%2$s</details>',
esc_html__( 'Additional context', 'woocommerce' ),
- stripslashes( $context )
+ esc_html( (string) $context )
);
$segments[2] = implode( ' ', $message_chunks );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Logging/LogHandlerFileV2Test.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Logging/LogHandlerFileV2Test.php
index cd1fa615a11..7ea59183d66 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Logging/LogHandlerFileV2Test.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Logging/LogHandlerFileV2Test.php
@@ -192,7 +192,7 @@ MESSAGE;
'multibyte' => '中文字',
'backslashes' => 'C:\MS-DOS\\',
),
- $context_delineator . '{"multibyte":"中文字","backslashes":"C:\MS-DOS\"}',
+ $context_delineator . '{"multibyte":"中文字","backslashes":"C:\\\\MS-DOS\\\\"}',
);
yield 'backtrace boolean only' => array(
array( 'backtrace' => true ),
@@ -256,6 +256,70 @@ MESSAGE;
$this->assertEquals( $expected_prefix . $expected . "\n", $actual_content );
}
+ /**
+ * Data provider for test_handle_context_is_valid_json.
+ *
+ * Only values whose types survive a JSON round trip belong here: a zero-fraction
+ * float like 1.0 encodes to 1 and decodes back as an integer, failing assertSame().
+ *
+ * @return array
+ */
+ public function provide_context_values(): array {
+ return array(
+ 'namespaced class name' => array( array( 'class' => 'Automattic\WooCommerce\Internal\Admin\Logging\LogHandlerFileV2' ) ),
+ 'windows path' => array( array( 'path' => 'C:\Windows\System32' ) ),
+ 'double quotes' => array( array( 'quote' => 'He said "hi" to "you"' ) ),
+ 'newlines and tabs' => array( array( 'multi' => "line1\nline2\ttab" ) ),
+ 'multibyte characters' => array( array( 'text' => '中文字 café 🎉' ) ),
+ 'mixed scalar types' => array(
+ array(
+ 'i' => 7,
+ 'f' => 3.14,
+ 'b' => true,
+ 'z' => null,
+ ),
+ ),
+ 'combined' => array(
+ array(
+ 'class' => 'Automattic\WooCommerce\Foo',
+ 'url' => 'https://example.com/x',
+ 'quote' => 'He said "hi"',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @testdox A log entry's CONTEXT is valid JSON that decodes back to the original context values.
+ *
+ * @dataProvider provide_context_values
+ *
+ * @see https://github.com/woocommerce/woocommerce/issues/62830
+ *
+ * @param array $context The context values to log, excluding the source.
+ */
+ public function test_handle_context_is_valid_json( array $context ): void {
+ $this->sut->handle(
+ time(),
+ 'debug',
+ 'Test log entry.',
+ array_merge( array( 'source' => 'test' ), $context )
+ );
+
+ $paths = glob( Settings::get_log_directory() . '*.log' );
+ $this->assertCount( 1, $paths );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $content = file_get_contents( reset( $paths ) );
+ $this->assertStringContainsString( ' CONTEXT: ', $content );
+
+ // The handler appends the context as JSON after " CONTEXT: ".
+ $json = explode( ' CONTEXT: ', $content, 2 )[1];
+ $decoded = json_decode( trim( $json ), true );
+
+ $this->assertSame( $context, $decoded );
+ }
+
/**
* @testdox Check that the delete_logs_before_timestamp method deletes files based on their created date.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Logging/PageControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Logging/PageControllerTest.php
new file mode 100644
index 00000000000..a24caa5f1d7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Logging/PageControllerTest.php
@@ -0,0 +1,152 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin\Logging;
+
+use Automattic\WooCommerce\Internal\Admin\Logging\{ LogHandlerFileV2, PageController, Settings };
+use WC_Unit_Test_Case;
+
+/**
+ * PageControllerTest class.
+ */
+class PageControllerTest extends WC_Unit_Test_Case {
+ /**
+ * "System Under Test", an instance of the class to be tested.
+ *
+ * @var PageController
+ */
+ private $sut;
+
+ /**
+ * Instance of the file log handler, used to write real log entries.
+ *
+ * @var LogHandlerFileV2
+ */
+ private $handler;
+
+ /**
+ * Set up before each test.
+ *
+ * @return void
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->sut = wc_get_container()->get( PageController::class );
+ $this->handler = new LogHandlerFileV2();
+ }
+
+ /**
+ * Tear down after each test.
+ *
+ * @return void
+ */
+ public function tearDown(): void {
+ $files = glob( Settings::get_log_directory() . '*.log' );
+ foreach ( $files as $file ) {
+ wp_delete_file( $file );
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * Write a log entry with the file handler, then format the resulting log file line with the page controller.
+ *
+ * @param array $context The context values to log, excluding the source.
+ *
+ * @return string The formatted line.
+ */
+ private function write_and_format_line( array $context ): string {
+ $this->handler->handle(
+ time(),
+ 'debug',
+ 'Test log entry.',
+ array_merge( array( 'source' => 'test' ), $context )
+ );
+
+ $paths = glob( Settings::get_log_directory() . '*.log' );
+ $this->assertCount( 1, $paths );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $line = file_get_contents( reset( $paths ) );
+
+ return $this->format_line( $line, 1 );
+ }
+
+ /**
+ * Invoke the private format_line method on the SUT, since its only caller reads
+ * query params through filter_input_array(), which is not settable from a test.
+ *
+ * @param string $line The unformatted log file line.
+ * @param int $line_number The line number.
+ *
+ * @return string
+ */
+ private function format_line( string $line, int $line_number ): string {
+ $method = new \ReflectionMethod( $this->sut, 'format_line' );
+ $method->setAccessible( true );
+
+ return $method->invoke( $this->sut, $line, $line_number );
+ }
+
+ /**
+ * Data provider for test_format_line_context_renders_as_valid_json.
+ *
+ * @return array
+ */
+ public function provide_context_values(): array {
+ return array(
+ 'namespaced class name' => array( array( 'class' => 'Automattic\\WooCommerce\\Internal\\Admin\\Logging\\LogHandlerFileV2' ) ),
+ 'windows path' => array( array( 'path' => 'C:\\Windows\\System32' ) ),
+ 'double quotes' => array( array( 'quote' => 'He said "hi" to "you"' ) ),
+ 'multibyte characters' => array( array( 'text' => '中文字 café 🎉' ) ),
+ );
+ }
+
+ /**
+ * @testdox A formatted log entry renders its context as a collapsible block whose content is valid JSON that decodes back to the original context values.
+ *
+ * @dataProvider provide_context_values
+ *
+ * @param array $context The context values to log, excluding the source.
+ */
+ public function test_format_line_context_renders_as_valid_json( array $context ): void {
+ $formatted = $this->write_and_format_line( $context );
+
+ $this->assertStringContainsString( 'has-context', $formatted );
+ $this->assertSame(
+ 1,
+ preg_match( '|<details><summary>[^<]+</summary>(.+)</details>|s', $formatted, $matches ),
+ 'The formatted line should contain a collapsible context block'
+ );
+
+ // Decode entities the same way a browser does when displaying the markup.
+ $json = html_entity_decode( $matches[1], ENT_QUOTES | ENT_HTML401 );
+ $decoded = json_decode( $json, true );
+
+ $this->assertSame( $context, $decoded );
+ }
+
+ /**
+ * @testdox HTML in a log entry's context values is entity-encoded in the formatted output.
+ */
+ public function test_format_line_escapes_html_in_context(): void {
+ $formatted = $this->write_and_format_line( array( 'payload' => '<script>alert("xss")</script>' ) );
+
+ $this->assertStringNotContainsString( '<script', $formatted );
+ $this->assertStringContainsString( '<script>', $formatted );
+ }
+
+ /**
+ * @testdox A log entry whose context is not valid JSON renders as a plain, escaped line without a collapsible context block.
+ */
+ public function test_format_line_malformed_context_renders_as_plain_line(): void {
+ $line = gmdate( 'Y-m-d\TH:i:sP' ) . ' DEBUG Test log entry. CONTEXT: {"unclosed":';
+ $formatted = $this->format_line( $line, 1 );
+
+ $this->assertStringNotContainsString( 'has-context', $formatted );
+ $this->assertStringNotContainsString( '<details>', $formatted );
+ $this->assertStringContainsString( 'CONTEXT: {"unclosed":', $formatted );
+ }
+}