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( '&lt;script&gt;', $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: {&quot;unclosed&quot;:', $formatted );
+	}
+}