From 2d2ae4303bdc796eebca19b44ac01c6b66471ea2 Mon Sep 17 00:00:00 2001
From: "C. Scott Ananian" <cscott@cscott.net>
Date: Tue, 14 Oct 2025 13:05:33 -0400
Subject: [PATCH] Sanitizer: disallow underscore and wide underscore in data-*
 attribute names

Also add wide underscore to the set of characters encoded in
attributes to ensure that Japenese double-underscore magic words
(which can begin with a double-wide underscore, U+FF3F) don't bypass
these protections.
Encode all underscores in attribute values so magic words that start
with double underscores but don't end with them are not replaced.

Bug: T407131
Change-Id: I351edf35b965ac3cc9e50e47649efd88d238b2c2
Co-Authored-By: SomeRandomDeveloper <thisisnotmyname275@gmail.com>
---
 includes/Parser/CoreParserFunctions.php | 4 +++-
 includes/Parser/Sanitizer.php           | 7 +++++--
 2 files changed, 8 insertions(+), 3 deletions(-)

diff --git a/includes/Parser/CoreParserFunctions.php b/includes/Parser/CoreParserFunctions.php
index 5512e4ba3be..68c37444a82 100644
--- a/includes/Parser/CoreParserFunctions.php
+++ b/includes/Parser/CoreParserFunctions.php
@@ -1363,7 +1363,9 @@ class CoreParserFunctions {
 	public static function anchorencode( $parser, $text ) {
 		$text = $parser->killMarkers( $text );
 		$section = substr( $parser->guessSectionNameFromWikiText( $text ), 1 );
-		return Sanitizer::safeEncodeAttribute( $section );
+		$encodedSection = Sanitizer::safeEncodeAttribute( $section );
+		// decode underscores to avoid breaking templates (T407131)
+		return str_replace( '&#95;', '_', $encodedSection );
 	}
 
 	/**
diff --git a/includes/Parser/Sanitizer.php b/includes/Parser/Sanitizer.php
index 71a7a96bc6b..713b9552e15 100644
--- a/includes/Parser/Sanitizer.php
+++ b/includes/Parser/Sanitizer.php
@@ -501,8 +501,10 @@ class Sanitizer {
 			# * Ensure attribute name will be accepted by the HTML
 			#   parser; see
 			#   https://github.com/whatwg/dom/issues/849#issuecomment-1007541209
+			# * Underscore and double-wide underscore (U+FF3F) is disallowed
+			#   here (but not in Parsoid): T407131
 			if ( (
-				!preg_match( '|^data-[^:= \t\r\n/>\0]*$|i', $attribute ) &&
+				!preg_match( '|^data-[^:= \t\r\n/>\0_＿]*$|i', $attribute ) &&
 				!array_key_exists( $attribute, $allowed )
 			) || self::isReservedDataAttribute( $attribute ) ) {
 				continue;
@@ -852,7 +854,8 @@ class Sanitizer {
 			'RFC'  => '&#82;FC',
 			'PMID' => '&#80;MID',
 			'|'    => '&#124;',
-			'__'   => '&#95;_',
+			'_'    => '&#95;',
+			'＿'    => '&#xFF3F;', // Japanese magic words
 		] );
 
 		# Stupid hack
-- 
2.51.0

