From f0b46731a78941625c062fcb63f2d1b828ffb5e2 Mon Sep 17 00:00:00 2001
From: "James D. Forrester" <jforrester@wikimedia.org>
Date: Thu, 11 Sep 2025 17:03:05 -0400
Subject: [PATCH] SECURITY: Do not let getErrorMessages() etc. return HTML
 ever, at least for now

This needs a proper fix, but this will prevent damage, at least.

Bug: T404392
Change-Id: I76ba6168e9723230f95f5a2785df90f9327a0148
---
 .../components/base/ZObjectSelector.vue       |  3 +--
 .../components/types/ZArgumentReference.vue   |  3 +--
 .../components/types/ZCode.vue                |  6 ++----
 .../types/ZObjectStringRenderer.vue           |  3 +--
 .../visualeditor/FunctionInputField.vue       |  3 +--
 .../FunctionMetadataDialog.vue                | 11 ++++++++---
 .../widgets/publish/PublishDialog.vue         |  3 +--
 .../ext.wikilambda.app/mixins/errorMixin.js   |  9 +++++++--
 .../FunctionMetadataDialog.test.js            |  3 +++
 .../widgets/publish/PublishDialog.test.js     | 19 +++++++++++++++++++
 tests/jest/fixtures/metadata.js               |  2 +-
 11 files changed, 45 insertions(+), 20 deletions(-)

diff --git a/resources/ext.wikilambda.app/components/base/ZObjectSelector.vue b/resources/ext.wikilambda.app/components/base/ZObjectSelector.vue
index 70bcaad0..bb6f4ee6 100644
--- a/resources/ext.wikilambda.app/components/base/ZObjectSelector.vue
+++ b/resources/ext.wikilambda.app/components/base/ZObjectSelector.vue
@@ -51,8 +51,7 @@
 				:type="error.type"
 				:inline="true"
 			>
-				<!-- eslint-disable vue/no-v-html -->
-				<div v-html="getErrorMessage( error )"></div>
+				<div>{{ getErrorMessage( error ) }}</div>
 			</cdx-message>
 		</div>
 	</span>
diff --git a/resources/ext.wikilambda.app/components/types/ZArgumentReference.vue b/resources/ext.wikilambda.app/components/types/ZArgumentReference.vue
index d07ebcf7..37fc27ae 100644
--- a/resources/ext.wikilambda.app/components/types/ZArgumentReference.vue
+++ b/resources/ext.wikilambda.app/components/types/ZArgumentReference.vue
@@ -34,8 +34,7 @@
 				:type="error.type"
 				:inline="true"
 			>
-				<!-- eslint-disable vue/no-v-html -->
-				<div v-html="getErrorMessage( error )"></div>
+				<div>{{ getErrorMessage( error ) }}</div>
 			</cdx-message>
 		</div>
 	</div>
diff --git a/resources/ext.wikilambda.app/components/types/ZCode.vue b/resources/ext.wikilambda.app/components/types/ZCode.vue
index 69f8c973..30ac3cb0 100644
--- a/resources/ext.wikilambda.app/components/types/ZCode.vue
+++ b/resources/ext.wikilambda.app/components/types/ZCode.vue
@@ -42,8 +42,7 @@
 					:type="error.type"
 					:inline="true"
 				>
-					<!-- eslint-disable-next-line vue/no-v-html -->
-					<div v-html="getErrorMessage( error )"></div>
+					<div>{{ getErrorMessage( error ) }}</div>
 				</cdx-message>
 			</template>
 		</wl-key-value-block>
@@ -79,8 +78,7 @@
 					:type="error.type"
 					:inline="true"
 				>
-					<!-- eslint-disable-next-line vue/no-v-html -->
-					<div v-html="getErrorMessage( error )"></div>
+					<div>{{ getErrorMessage( error ) }}</div>
 				</cdx-message>
 			</template>
 		</wl-key-value-block>
diff --git a/resources/ext.wikilambda.app/components/types/ZObjectStringRenderer.vue b/resources/ext.wikilambda.app/components/types/ZObjectStringRenderer.vue
index b69a50c3..0c6ca02f 100644
--- a/resources/ext.wikilambda.app/components/types/ZObjectStringRenderer.vue
+++ b/resources/ext.wikilambda.app/components/types/ZObjectStringRenderer.vue
@@ -31,8 +31,7 @@
 				class="ext-wikilambda-app-object-string-renderer__error"
 			>
 				<cdx-message :type="fieldErrors[0].type" :inline="true">
-					<!-- eslint-disable vue/no-v-html -->
-					<span v-html="getErrorMessage( fieldErrors[0] )"></span>
+					<span>{{ getErrorMessage( fieldErrors[0] ) }}</span>
 				</cdx-message>
 				<a v-if="showExamplesLink" @click="openExamplesDialog">
 					{{ $i18n( 'wikilambda-string-renderer-examples-title' ).text() }}
diff --git a/resources/ext.wikilambda.app/components/visualeditor/FunctionInputField.vue b/resources/ext.wikilambda.app/components/visualeditor/FunctionInputField.vue
index 74acbb8a..060d92a2 100644
--- a/resources/ext.wikilambda.app/components/visualeditor/FunctionInputField.vue
+++ b/resources/ext.wikilambda.app/components/visualeditor/FunctionInputField.vue
@@ -32,8 +32,7 @@
 			</span>
 		</template>
 		<template v-if="showValidation && !!errorMessage" #error>
-			<!-- eslint-disable-next-line vue/no-v-html -->
-			<span v-html="errorMessage"></span>
+			<div>{{ getErrorMessage( error ) }}</div>
 		</template>
 	</cdx-field>
 </template>
diff --git a/resources/ext.wikilambda.app/components/widgets/function-evaluator/FunctionMetadataDialog.vue b/resources/ext.wikilambda.app/components/widgets/function-evaluator/FunctionMetadataDialog.vue
index d41f1540..7d1b75c6 100644
--- a/resources/ext.wikilambda.app/components/widgets/function-evaluator/FunctionMetadataDialog.vue
+++ b/resources/ext.wikilambda.app/components/widgets/function-evaluator/FunctionMetadataDialog.vue
@@ -44,8 +44,7 @@
 				class="ext-wikilambda-metadata-dialog-errors"
 				:type="error.type"
 			>
-				<!-- eslint-disable vue/no-v-html -->
-				<div v-html="getErrorMessage( error )"></div>
+				<div>{{ getErrorMessage( error ) }}</div>
 			</cdx-message>
 		</div>
 		<div v-else class="ext-wikilambda-app-function-metadata-dialog__body">
@@ -667,7 +666,13 @@ module.exports = exports = defineComponent( {
 				for ( const arg of data.stringArgs ) {
 					const key = this.getLabelData( arg.key );
 					const keySpan = `<span dir="${ key.langDir }" lang="${ key.langCode }">${ key.labelOrUntitled }</span>`;
-					list.push( `<li>${ keySpan }: "${ arg.value }"</li>` );
+
+					// SECURITY: Escape any HTML in the argument value
+					const div = document.createElement( 'div' );
+					div.appendChild( document.createTextNode( arg.value ) );
+					const escapedArg = div.innerHTML;
+
+					list.push( `<li>${ keySpan }: "${ escapedArg }"</li>` );
 				}
 				return `<ul>${ list.join( '' ) }</ul>`;
 			}
diff --git a/resources/ext.wikilambda.app/components/widgets/publish/PublishDialog.vue b/resources/ext.wikilambda.app/components/widgets/publish/PublishDialog.vue
index cf2b7f16..a08f1f5f 100644
--- a/resources/ext.wikilambda.app/components/widgets/publish/PublishDialog.vue
+++ b/resources/ext.wikilambda.app/components/widgets/publish/PublishDialog.vue
@@ -30,8 +30,7 @@
 					class="ext-wikilambda-app-publish-dialog__error"
 					:type="error.type"
 				>
-					<!-- eslint-disable vue/no-v-html -->
-					<div v-html="getErrorMessage( error )"></div>
+					<div>{{ getErrorMessage( error ) }}</div>
 				</cdx-message>
 			</div>
 
diff --git a/resources/ext.wikilambda.app/mixins/errorMixin.js b/resources/ext.wikilambda.app/mixins/errorMixin.js
index 8afa30da..9cb57eee 100644
--- a/resources/ext.wikilambda.app/mixins/errorMixin.js
+++ b/resources/ext.wikilambda.app/mixins/errorMixin.js
@@ -34,15 +34,20 @@ module.exports = exports = {
 
 		/**
 		 * Returns the translated message for a given error code.
-		 * Error messages can have html tags.
+		 * Raw error messages cannot have HTML tags, as they are escaped.
 		 *
 		 * @memberof module:ext.wikilambda.app.mixins.errorMixin
 		 * @param {Object} error
 		 * @return {string}
 		 */
 		getErrorMessage: function ( error ) {
+			if ( error.message ) {
+				const div = document.createElement( 'div' );
+				div.appendChild( document.createTextNode( error.message ) );
+				return div.innerHTML;
+			}
 			// eslint-disable-next-line mediawiki/msg-doc
-			return error.message || this.$i18n( error.code ).parse();
+			return this.$i18n( error.code ).parse();
 		},
 
 		/**
diff --git a/tests/jest/components/widgets/function-evaluator/FunctionMetadataDialog.test.js b/tests/jest/components/widgets/function-evaluator/FunctionMetadataDialog.test.js
index d16eff45..e467cfa9 100644
--- a/tests/jest/components/widgets/function-evaluator/FunctionMetadataDialog.test.js
+++ b/tests/jest/components/widgets/function-evaluator/FunctionMetadataDialog.test.js
@@ -390,6 +390,9 @@ describe( 'dialog', () => {
 			// Metadata body is now reflecting a different metadata set
 			sections = wrapper.findAllComponents( { name: 'cdx-accordion' } );
 			expect( sections.length ).toBe( 3 );
+
+			// SECURITY: Ensure malicious HTML in error message values is also escaped
+			expect( sections[ 0 ].html() ).toContain( '"some error in child function call: &lt;button onmouseover="window.location = \'//www.example.com\'"&gt;"' );
 		} );
 	} );
 } );
diff --git a/tests/jest/components/widgets/publish/PublishDialog.test.js b/tests/jest/components/widgets/publish/PublishDialog.test.js
index e5eeb978..caf44b4c 100644
--- a/tests/jest/components/widgets/publish/PublishDialog.test.js
+++ b/tests/jest/components/widgets/publish/PublishDialog.test.js
@@ -91,6 +91,25 @@ describe( 'Publish Dialog', () => {
 		expect( messages[ 1 ].text() ).toBe( 'Unable to complete request. Please try again.' );
 	} );
 
+	it( 'renders escaped page errors', () => {
+		const errors = [ {
+			message: "<button onmouseover=\"window.location = '//www.example.com'\">",
+			code: undefined,
+			type: Constants.ERROR_TYPES.ERROR
+		} ];
+		store.getErrors = createGettersWithFunctionsMock( errors );
+
+		const wrapper = mount( PublishDialog, {
+			props: { showDialog: true },
+			global: { stubs: dialogGlobalStubs }
+		} );
+
+		const messages = wrapper.findAllComponents( { name: 'cdx-message' } );
+		expect( messages.length ).toBe( 1 );
+		expect( messages[ 0 ].props( 'type' ) ).toBe( 'error' );
+		expect( messages[ 0 ].text() ).toBe( '&lt;button onmouseover="window.location = \'//www.example.com\'"&gt;' );
+	} );
+
 	it( 'closes the dialog when click cancel button', () => {
 		const wrapper = mount( PublishDialog, {
 			props: { showDialog: true },
diff --git a/tests/jest/fixtures/metadata.js b/tests/jest/fixtures/metadata.js
index 8972fdc5..f9f94695 100644
--- a/tests/jest/fixtures/metadata.js
+++ b/tests/jest/fixtures/metadata.js
@@ -75,7 +75,7 @@ const metadataChild2 = convertSetToMap( {
 				Z7K1: "Z885",
 				Z885K1: "Z500"
 			},
-			Z500K1: "some error in child function call",
+			Z500K1: "some error in child function call: <button onmouseover=\"window.location = '//www.example.com'\">",
 		}
 	},
 	// duration:
-- 
2.39.5 (Apple Git-154)

