From 8b650986da87cb71a03fdff60f9e0358da8409d0 Mon Sep 17 00:00:00 2001
From: Brian Wolff <bawolff+wn@gmail.com>
Date: Thu, 27 Feb 2025 03:00:58 -0800
Subject: [PATCH] =?UTF-8?q?Avoid=20attacks=20in=20API=20related=20to=20uni?=
 =?UTF-8?q?code=20NFC=20and=20decomposed=20"=E2=89=AF"?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The character U+0338 can eat > when normalized allowing breaking
out of HTML

Bug: T387130
Change-Id: Ie7dabe7e4fc6a2ce777acd6660eebb746b98ca46
---
 includes/api/ApiResult.php | 30 ++++++++++++++++++++++++------
 1 file changed, 24 insertions(+), 6 deletions(-)

diff --git a/includes/api/ApiResult.php b/includes/api/ApiResult.php
index 65676944232..81a3df51010 100644
--- a/includes/api/ApiResult.php
+++ b/includes/api/ApiResult.php
@@ -22,6 +22,7 @@ namespace MediaWiki\Api;
 
 use Exception;
 use InvalidArgumentException;
+use MediaWiki\Logger\LoggerFactory;
 use MediaWiki\MediaWikiServices;
 use MediaWiki\Message\Message;
 use RuntimeException;
@@ -368,12 +369,7 @@ class ApiResult implements ApiSerializable {
 		}
 
 		if ( is_string( $value ) ) {
-			// Optimization: avoid querying the service locator for each value.
-			static $contentLanguage = null;
-			if ( !$contentLanguage ) {
-				$contentLanguage = MediaWikiServices::getInstance()->getContentLanguage();
-			}
-			$value = $contentLanguage->normalize( $value );
+			$value = self::normalize( $value );
 		} elseif ( is_array( $value ) ) {
 			foreach ( $value as $k => $v ) {
 				$value[$k] = self::validateValue( $v );
@@ -388,6 +384,28 @@ class ApiResult implements ApiSerializable {
 		return $value;
 	}
 
+	private static function normalize( $str ) {
+		// Optimization: avoid querying the service locator for each value.
+		static $contentLanguage = null;
+		if ( !$contentLanguage ) {
+			$contentLanguage = MediaWikiServices::getInstance()->getContentLanguage();
+		}
+		// Be careful about > and \u0337 (T387130)
+		// If we detect that the number of ≯ characters has changed, that
+		// probably means a malicious person ate a '>' in an html tag.
+		$normalized = $contentLanguage->normalize( $str );
+		// Important, we are matching the precomposed form here
+		$matchesOriginal = preg_match_all( "/≯/", $str );
+		$matchesNew = preg_match_all( "/≯/", $normalized );
+		if ( $matchesOriginal !== $matchesNew ) {
+			// Potential attack. Remove all combining solidus just to be safe.
+			$logger = LoggerFactory::getInstance( 'unicode' );
+			$logger->warning( "Potential normalization attack (T387130)", [ "string" => $str ] );
+			return $contentLanguage->normalize( str_replace( "\u{0338}", "�", $str ) );
+		}
+		return $normalized;
+	}
+
 	/**
 	 * Add value to the output data at the given path.
 	 *
-- 
2.39.2

