From 2bb3784882bb95db73db1f9846b7c02972b424cb Mon Sep 17 00:00:00 2001
From: "C. Scott Ananian" <cscott@cscott.net>
Date: Tue, 25 Feb 2025 12:02:34 -0500
Subject: [PATCH 1/5] Ensure emitted HTML is safe against Unicode NFC
 normalization

Ensure that Unicode NFC normalization can be applied to our HTML
output safely.  Even though the W3C officially recommends against
normalizing HTML

https://www.w3.org/International/questions/qa-html-css-normalization#converting

this is still easily done inadvertently, especially when using the
MediaWiki action API which normalizes parameters and results by
default.

See also I671648603c4635a35585c860b4857f5ea085e47f in Parsoid, and
T266140 / I2e78e660ba1867744e34eda7d00ea527ec016b71 for another similar
issue.

The following changes are made:

* The various HTML serializers (Remex/Tidy-derived, as well as the
  Html::* helpers) are tweaked to entity-escape U+0338 wherever it
  appears.

* Similarly, Message::escaped() is tweaked to entity-escape U+0338.

* Finally, a post-processing pass is added to the OutputTransform
  pipeline to catch any remaining U+0338 and entity-escape them.
  This catches U+0338 added during any of the previous OutputTransform
  stages (like TOC insertion, section edit links, etc).
  *When backporting* this code will likely need to be moved to
  ParserOutput::getText(), as the OutputTransform pipeline wasn't added
  until MW 1.42.

Bug: T387130
Change-Id: I66564e14e730f5393f4fa5780b80f24de6075af5
---
 autoload.php                                  |   1 +
 includes/Html/Html.php                        |   2 +
 includes/Html/HtmlHelperTrait.php             |   4 ++
 .../DefaultOutputPipelineFactory.php          |   5 +++
 includes/OutputTransform/Stages/HardenNFC.php |  23 ++++++++++
 includes/language/Message/Message.php         |   6 ++-
 includes/parser/Sanitizer.php                 |   8 +++-
 includes/tidy/RemexCompatFormatter.php        |   2 +
 tests/parser/badCharacters.txt                | Bin 1892 -> 3623 bytes
 tests/phpunit/includes/Html/HtmlTest.php      |  12 +++++
 .../OutputTransform/Stages/HardenNFCTest.php  |  41 ++++++++++++++++++
 .../phpunit/includes/language/MessageTest.php |  14 +++---
 .../includes/parser/SanitizerUnitTest.php     |   2 +
 .../unit/includes/tidy/RemexDriverTest.php    |   5 +++
 14 files changed, 116 insertions(+), 9 deletions(-)
 create mode 100644 includes/OutputTransform/Stages/HardenNFC.php
 create mode 100644 tests/phpunit/includes/OutputTransform/Stages/HardenNFCTest.php

diff --git a/autoload.php b/autoload.php
index 59ac3f318e5..1c90eb72b9e 100644
--- a/autoload.php
+++ b/autoload.php
@@ -1945,6 +1945,7 @@ $wgAutoloadLocalClasses = [
 	'MediaWiki\\OutputTransform\\Stages\\HandleParsoidSectionLinks' => __DIR__ . '/includes/OutputTransform/Stages/HandleParsoidSectionLinks.php',
 	'MediaWiki\\OutputTransform\\Stages\\HandleSectionLinks' => __DIR__ . '/includes/OutputTransform/Stages/HandleSectionLinks.php',
 	'MediaWiki\\OutputTransform\\Stages\\HandleTOCMarkers' => __DIR__ . '/includes/OutputTransform/Stages/HandleTOCMarkers.php',
+	'MediaWiki\\OutputTransform\\Stages\\HardenNFC' => __DIR__ . '/includes/OutputTransform/Stages/HardenNFC.php',
 	'MediaWiki\\OutputTransform\\Stages\\HydrateHeaderPlaceholders' => __DIR__ . '/includes/OutputTransform/Stages/HydrateHeaderPlaceholders.php',
 	'MediaWiki\\OutputTransform\\Stages\\ParsoidLocalization' => __DIR__ . '/includes/OutputTransform/Stages/ParsoidLocalization.php',
 	'MediaWiki\\OutputTransform\\Stages\\RenderDebugInfo' => __DIR__ . '/includes/OutputTransform/Stages/RenderDebugInfo.php',
diff --git a/includes/Html/Html.php b/includes/Html/Html.php
index e383fd18051..0b80411adce 100644
--- a/includes/Html/Html.php
+++ b/includes/Html/Html.php
@@ -28,6 +28,7 @@ namespace MediaWiki\Html;
 use MediaWiki\Json\FormatJson;
 use MediaWiki\MainConfigNames;
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Parser\Sanitizer;
 use MediaWiki\Request\ContentSecurityPolicy;
 use UnexpectedValueException;
 
@@ -193,6 +194,7 @@ class Html {
 		if ( isset( self::$voidElements[$element] ) ) {
 			return $start;
 		} else {
+			$contents = Sanitizer::escapeCombiningChar( $contents ?? '' );
 			return $start . $contents . self::closeElement( $element );
 		}
 	}
diff --git a/includes/Html/HtmlHelperTrait.php b/includes/Html/HtmlHelperTrait.php
index 3d61c696501..1a938d8825e 100644
--- a/includes/Html/HtmlHelperTrait.php
+++ b/includes/Html/HtmlHelperTrait.php
@@ -3,6 +3,7 @@
 namespace MediaWiki\Html;
 
 use Wikimedia\Assert\Assert;
+use Wikimedia\RemexHtml\Serializer\HtmlFormatter;
 use Wikimedia\RemexHtml\Serializer\SerializerNode;
 
 /**
@@ -23,6 +24,9 @@ trait HtmlHelperTrait {
 		parent::__construct( $options );
 		$this->shouldModifyCallback = $shouldModifyCallback;
 		$this->modifyCallback = $modifyCallback;
+		// Escape U+0338 (T387130)
+		'@phan-var HtmlFormatter $this';
+		$this->textEscapes["\u{0338}"] = '&#x338;';
 	}
 
 	public function element( SerializerNode $parent, SerializerNode $node, $contents ) {
diff --git a/includes/OutputTransform/DefaultOutputPipelineFactory.php b/includes/OutputTransform/DefaultOutputPipelineFactory.php
index e1d393cf5c8..63e60e53d9b 100644
--- a/includes/OutputTransform/DefaultOutputPipelineFactory.php
+++ b/includes/OutputTransform/DefaultOutputPipelineFactory.php
@@ -14,6 +14,7 @@ use MediaWiki\OutputTransform\Stages\ExtractBody;
 use MediaWiki\OutputTransform\Stages\HandleParsoidSectionLinks;
 use MediaWiki\OutputTransform\Stages\HandleSectionLinks;
 use MediaWiki\OutputTransform\Stages\HandleTOCMarkers;
+use MediaWiki\OutputTransform\Stages\HardenNFC;
 use MediaWiki\OutputTransform\Stages\HydrateHeaderPlaceholders;
 use MediaWiki\OutputTransform\Stages\ParsoidLocalization;
 use MediaWiki\OutputTransform\Stages\RenderDebugInfo;
@@ -101,6 +102,10 @@ class DefaultOutputPipelineFactory {
 		'HydrateHeaderPlaceholders' => [
 			'class' => HydrateHeaderPlaceholders::class,
 		],
+		# This should be last, in order to ensure final output is hardened
+		'HardenNFC' => [
+			'class' => HardenNFC::class,
+		],
 	];
 
 	public function __construct(
diff --git a/includes/OutputTransform/Stages/HardenNFC.php b/includes/OutputTransform/Stages/HardenNFC.php
new file mode 100644
index 00000000000..66108268ea0
--- /dev/null
+++ b/includes/OutputTransform/Stages/HardenNFC.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace MediaWiki\OutputTransform\Stages;
+
+use MediaWiki\OutputTransform\ContentTextTransformStage;
+use MediaWiki\Parser\ParserOptions;
+use MediaWiki\Parser\ParserOutput;
+use MediaWiki\Parser\Sanitizer;
+
+/**
+ * Hardens the output against NFC normalization (T387130).
+ * @internal
+ */
+class HardenNFC extends ContentTextTransformStage {
+
+	public function shouldRun( ParserOutput $po, ?ParserOptions $popts, array $options = [] ): bool {
+		return true;
+	}
+
+	protected function transformText( string $text, ParserOutput $po, ?ParserOptions $popts, array &$options ): string {
+		return Sanitizer::escapeCombiningChar( $text );
+	}
+}
diff --git a/includes/language/Message/Message.php b/includes/language/Message/Message.php
index 04c52a13324..ad48d0de032 100644
--- a/includes/language/Message/Message.php
+++ b/includes/language/Message/Message.php
@@ -34,6 +34,7 @@ use MediaWiki\Page\PageReference;
 use MediaWiki\Page\PageReferenceValue;
 use MediaWiki\Parser\Parser;
 use MediaWiki\Parser\ParserOutput;
+use MediaWiki\Parser\Sanitizer;
 use MediaWiki\StubObject\StubUserLang;
 use MediaWiki\Title\Title;
 use RuntimeException;
@@ -1019,7 +1020,7 @@ class Message implements Stringable, MessageSpecifier, Serializable {
 			// '⧼' is used instead of '<' to side-step any
 			// double-escaping issues.
 			// (Keep synchronised with mw.Message#toString in JS.)
-			return '⧼' . htmlspecialchars( $this->key ) . '⧽';
+			return '⧼' . Sanitizer::escapeCombiningChar( htmlspecialchars( $this->key ) ) . '⧽';
 		}
 
 		if ( in_array( $this->getLanguage()->getCode(), [ 'qqx', 'x-xss' ] ) ) {
@@ -1056,6 +1057,7 @@ class Message implements Stringable, MessageSpecifier, Serializable {
 		} elseif ( $format === self::FORMAT_ESCAPED ) {
 			$string = $this->transformText( $string );
 			$string = htmlspecialchars( $string, ENT_QUOTES, 'UTF-8', false );
+			$string = Sanitizer::escapeCombiningChar( $string );
 		}
 
 		# Raw parameter replacement
@@ -1535,7 +1537,7 @@ class Message implements Stringable, MessageSpecifier, Serializable {
 			case self::FORMAT_BLOCK_PARSE:
 			case self::FORMAT_ESCAPED:
 			default:
-				return htmlspecialchars( $plaintext, ENT_QUOTES );
+				return Sanitizer::escapeCombiningChar( htmlspecialchars( $plaintext, ENT_QUOTES ) );
 		}
 	}
 
diff --git a/includes/parser/Sanitizer.php b/includes/parser/Sanitizer.php
index 570e26326a3..5ef67e07c5c 100644
--- a/includes/parser/Sanitizer.php
+++ b/includes/parser/Sanitizer.php
@@ -1041,6 +1041,12 @@ class Sanitizer {
 			$class ), '_' );
 	}
 
+	public static function escapeCombiningChar( string $html ): string {
+		return strtr( $html, [
+			"\u{0338}" => '&#x338;', # T387130
+		] );
+	}
+
 	/**
 	 * Given HTML input, escape with htmlspecialchars but un-escape entities.
 	 * This allows (generally harmless) entities like &#160; to survive.
@@ -1056,7 +1062,7 @@ class Sanitizer {
 		# hurt. Use ENT_SUBSTITUTE so that incorrectly truncated multibyte characters
 		# don't cause the entire string to disappear.
 		$html = htmlspecialchars( $html, ENT_QUOTES | ENT_SUBSTITUTE );
-		return $html;
+		return self::escapeCombiningChar( $html );
 	}
 
 	/**
diff --git a/includes/tidy/RemexCompatFormatter.php b/includes/tidy/RemexCompatFormatter.php
index caedc2828b7..8323986c0e6 100644
--- a/includes/tidy/RemexCompatFormatter.php
+++ b/includes/tidy/RemexCompatFormatter.php
@@ -28,6 +28,8 @@ class RemexCompatFormatter extends HtmlFormatter {
 		// Escape non-breaking space
 		$this->attributeEscapes["\u{00A0}"] = '&#160;';
 		$this->textEscapes["\u{00A0}"] = '&#160;';
+		// Escape U+0338 (T387130)
+		$this->textEscapes["\u{0338}"] = '&#x338;';
 		// Disable escaping of '&', because we expect to see entities, due to 'ignoreCharRefs'
 		unset( $this->attributeEscapes["&"] );
 		unset( $this->textEscapes["&"] );
diff --git a/tests/parser/badCharacters.txt b/tests/parser/badCharacters.txt
index 10e5fd3b5f80c9ea2fd5d7bff3c8d9b5acd0fd7d..680ab05055ad28b8d7f58694810f8d465c26fd5e 100644
GIT binary patch
literal 3623
zcmd5<ZExE)5bnoK!2ZKk2C@cAtj1lp%(XmpU5XB9+5$<j4_Oh=(%B+Ri2_MSF^2uG
z{)qml-I0>xBrfu@b=W7%<nivF=ibO392e5UR4N2p=!MEbS855FuAGn+IJC~f!pdp}
zw#G~f1z91C$Q&9Q^}rRvfitp(MGaP$NJq{cU@o0Dwoejb8=fnJB42{imW0_L>xHQ{
zutZ@jGzC^*u+(SB4uIJk{Pg2fIy~8iqpM2E0X;_+a|H(1^99+}{{Mpu|DqmgI3}5z
zY-pM9X1c6}lM@1~YbQy=1eI=p9P+3QDm)XF3;r|+!XTJMRS|qr#cI&BoDey(ayT=>
zk<X>h5gO?Vco%H-1F@|cG9YJ_Hkgwd4|a_O=AHz|&Ls_`9;_2pE|jj&Tj)m*AGV&`
z=sKvhaf8B@YKMLs7oAar2xB_nQ&{B@|1$B>!8t{*qEISX&FzpZoe3rL3+CX>``7Tu
za$IZ7xA|Zr=aNGkofOX1!z5{%rjO^MtQGc&*au$q2Nq=?qES5_35uyo<4RApBA#Q&
zl(069NlmcN3S%e?PSbd^+IY+!j}kGCMoB%s2b77(=C&3Y!lT+C1(0&gBMPx0A`R9?
zXK1Z9>AST*r>}%9ZiD@+>)20j20t~3H-X<w3e(Zq+{ntIn90mX_m08cY%j$2ZlV2W
z=t7>}1KYnD?agEa;{QuBT8kUmxLV3iOfJg!Hn~g9SyFAt>g(Tim7`JAalSwmZ?N+D
zqJIC~<0nt{cVNQ(m;3=?sew7^mW+sI(^Sr)X2z`+%|Ruop})qN$d<2Y%uuiX_3dFg
z70MzBy)<H0(&0nu(CG<Kn<>@P7(B$T1JLAaoX&FLMAuf+KO=yc+q#X1K<C=t8t()Z
zzR{AJ9TsbhsE;t?iNey(EHL`(68Y>HnkY(B*y^1VxM%Q|n)()`Y1Azob9ZHlPaom9
z(P5Lnd7W^V=!AE%Gc}B$7*OPLJRk1S{L5&1p`y=fQBKhCnsf?>fu~bHbM<sO+$T<H
zhMI)i|8xI2{PgjZ96H_Nrh&uGCGR7(_-AXD1{*BV+D1#?4>p@3cvnq%#NJdpL6W+t
zd5W~u`RpI%@~j&oWwTp^aKpvtARdpHlRS-E3;`A^j>p$vjgn$89?^8D*0l27#fE5-
zw$E?wPUspmM;DJzMhT1Yzzef1@j>Ymf}cGx=~@W3*cZA#@n~jK{(oyP-HQX0X?#Fi
z&@K-zCrtpuXSsxK<>80U!`9J+h@^>HuPE_Jc5jY-JTG&+is%21<G~FauRM>(Uy9>Z
ziigW$G-?A4T;IEt&h7tQPUOe2k5s6G5Tc~rr{IS~-FR@154P3N)prO|$Qf4NfAOOK
z%f2s?{@@2<W?7@A+gRBQUkasooc@+m8MJxht6@#%z<1?UWwm0Vx3MW7e18|b<w?%N
zyI`b(U&pwXh}SUk-Yqb8O=6V>D-5fh>ppUS_E+p)58h};uMz()D$wA!76hoaQ~hCp
z*ZekZRLIv(VV3?a#LpRjKPP4W_sAJRZ!uwv6f=5>pj#MzcF=AZ3Eco|-vZqO;L_;%
crjUi^6`p)uQw@ur=+#b_i}l#=cAE_AZ&W_L6951J

delta 11
ScmZ23^Mr3hAKT<W-fI9IH3Y!`

diff --git a/tests/phpunit/includes/Html/HtmlTest.php b/tests/phpunit/includes/Html/HtmlTest.php
index bac57167414..2e4a516c963 100644
--- a/tests/phpunit/includes/Html/HtmlTest.php
+++ b/tests/phpunit/includes/Html/HtmlTest.php
@@ -96,6 +96,18 @@ class HtmlTest extends MediaWikiIntegrationTestCase {
 			Html::element( 'element', [], '' ),
 			'Close tag for empty element (array, string)'
 		);
+
+		$this->assertEquals(
+			"<p test=\"\u{0338}&quot;&amp;\">&#x338; &amp; &lt; ></p>",
+			Html::element( 'p', [ 'test' => "\u{0338}\"&" ], "\u{0338} & < >" ),
+			'Attribute and content escaping'
+		);
+
+		$this->assertEquals(
+			'<p>&#x338; &amp;</p>',
+			Html::rawElement( 'p', [], "\u{0338} &amp;" ),
+			"Combining characters escaped even in raw contents (T387130)"
+		);
 	}
 
 	public function dataXmlMimeType() {
diff --git a/tests/phpunit/includes/OutputTransform/Stages/HardenNFCTest.php b/tests/phpunit/includes/OutputTransform/Stages/HardenNFCTest.php
new file mode 100644
index 00000000000..20282ef89ff
--- /dev/null
+++ b/tests/phpunit/includes/OutputTransform/Stages/HardenNFCTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace MediaWiki\Tests\OutputTransform\Stages;
+
+use MediaWiki\Config\ServiceOptions;
+use MediaWiki\OutputTransform\OutputTransformStage;
+use MediaWiki\OutputTransform\Stages\HardenNFC;
+use MediaWiki\Parser\ParserOutput;
+use MediaWiki\Tests\OutputTransform\OutputTransformStageTestBase;
+use Psr\Log\NullLogger;
+
+/**
+ * @covers \MediaWiki\OutputTransform\Stages\HardenNFC
+ */
+class HardenNFCTest extends OutputTransformStageTestBase {
+
+	public function createStage(): OutputTransformStage {
+		return new HardenNFC(
+			new ServiceOptions( [] ),
+			new NullLogger()
+		);
+	}
+
+	public function provideShouldRun(): array {
+		return [
+			[ new ParserOutput(), null, [] ]
+		];
+	}
+
+	public function provideShouldNotRun(): array {
+		$this->markTestSkipped( 'HydrateHeaderPlaceHolders should always run' );
+	}
+
+	public function provideTransform(): array {
+		$text = "<h1>\u{0338}</h1>";
+		$expectedText = "<h1>&#x338;</h1>";
+		return [
+			[ new ParserOutput( $text ), null, [], new ParserOutput( $expectedText ) ],
+		];
+	}
+}
diff --git a/tests/phpunit/includes/language/MessageTest.php b/tests/phpunit/includes/language/MessageTest.php
index b8cea329dd7..8a34c7d87d0 100644
--- a/tests/phpunit/includes/language/MessageTest.php
+++ b/tests/phpunit/includes/language/MessageTest.php
@@ -280,6 +280,7 @@ class MessageTest extends MediaWikiLangTestCase {
 				'⧼script&gt;alert(1)&lt;/script⧽' ],
 			[ 'script>alert(1)</script', 'plain', '⧼script&gt;alert(1)&lt;/script⧽',
 				'⧼script&gt;alert(1)&lt;/script⧽' ],
+			[ "\u{0338}isolated combining char", 'escaped', '⧼&#x338;isolated combining char⧽', '⧼&#x338;isolated combining char⧽' ],
 		];
 	}
 
@@ -307,6 +308,7 @@ class MessageTest extends MediaWikiLangTestCase {
 				'&lt;script&gt;alert(1)&lt;/script&gt;' ],
 			[ '<script>alert(1)</script>', 'plain', '<script>alert(1)</script>',
 				'&lt;script&gt;alert(1)&lt;/script&gt;' ],
+			[ "\u{0338}isolated combining char", 'escaped', '&#x338;isolated combining char', '&#x338;isolated combining char' ],
 		];
 	}
 
@@ -567,28 +569,28 @@ class MessageTest extends MediaWikiLangTestCase {
 	public static function providePlaintextParams() {
 		return [
 			[
-				'one $2 <div>foo</div> [[Bar]] {{Baz}} &lt;',
+				"one $2 <div>\u{0338}foo</div> [[Bar]] {{Baz}} &lt;",
 				'plain',
 			],
 
 			[
 				// expect
-				'one $2 <div>foo</div> [[Bar]] {{Baz}} &lt;',
+				"one $2 <div>\u{0338}foo</div> [[Bar]] {{Baz}} &lt;",
 				// format
 				'text',
 			],
 			[
-				'one $2 &lt;div&gt;foo&lt;/div&gt; [[Bar]] {{Baz}} &amp;lt;',
+				'one $2 &lt;div&gt;&#x338;foo&lt;/div&gt; [[Bar]] {{Baz}} &amp;lt;',
 				'escaped',
 			],
 
 			[
-				'one $2 &lt;div&gt;foo&lt;/div&gt; [[Bar]] {{Baz}} &amp;lt;',
+				'one $2 &lt;div&gt;&#x338;foo&lt;/div&gt; [[Bar]] {{Baz}} &amp;lt;',
 				'parse',
 			],
 
 			[
-				"<p>one $2 &lt;div&gt;foo&lt;/div&gt; [[Bar]] {{Baz}} &amp;lt;\n</p>",
+				"<p>one $2 &lt;div&gt;&#x338;foo&lt;/div&gt; [[Bar]] {{Baz}} &amp;lt;\n</p>",
 				'parseAsBlock',
 			],
 		];
@@ -601,7 +603,7 @@ class MessageTest extends MediaWikiLangTestCase {
 		$msg = new RawMessage( '$1 $2' );
 		$params = [
 			'one $2',
-			'<div>foo</div> [[Bar]] {{Baz}} &lt;',
+			"<div>\u{0338}foo</div> [[Bar]] {{Baz}} &lt;",
 		];
 		$this->assertSame(
 			$expect,
diff --git a/tests/phpunit/unit/includes/parser/SanitizerUnitTest.php b/tests/phpunit/unit/includes/parser/SanitizerUnitTest.php
index 8a54f074f8a..210449370c5 100644
--- a/tests/phpunit/unit/includes/parser/SanitizerUnitTest.php
+++ b/tests/phpunit/unit/includes/parser/SanitizerUnitTest.php
@@ -204,6 +204,8 @@ class SanitizerUnitTest extends MediaWikiUnitTestCase {
 			[ 'a¡b', 'a&#161;b' ],
 			[ 'foo&#039;bar', "foo'bar" ],
 			[ '&lt;script&gt;foo&lt;/script&gt;', '<script>foo</script>' ],
+			[ '&#x338;', "\u{0338}" ],
+			[ '&#x338;', '&#x338;' ],
 		];
 	}
 
diff --git a/tests/phpunit/unit/includes/tidy/RemexDriverTest.php b/tests/phpunit/unit/includes/tidy/RemexDriverTest.php
index 390e80f0341..ad9424fca81 100644
--- a/tests/phpunit/unit/includes/tidy/RemexDriverTest.php
+++ b/tests/phpunit/unit/includes/tidy/RemexDriverTest.php
@@ -309,6 +309,11 @@ class RemexDriverTest extends MediaWikiUnitTestCase {
 			'<meta foo="bar"/>foo',
 			"<meta foo=\"bar\" /><p>foo</p>",
 		],
+		[
+			'Unicode combining characters (T387130)',
+			"<p>\u{0338} <!--comment-->\u{0338}</p>",
+			'<p>&#x338; <!--comment-->&#x338;</p>',
+		],
 	];
 
 	public static function provider() {
-- 
2.43.0

