CVE-2022-31160: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: jQuery UI is a curated set of user interface interactions, effects, widgets, and themes built on top of jQuery. Versions prior to 1.13.2 are potentially vulnerable to cross-site scripting. Initializing a checkboxradio widget on an input enclosed within a label makes that parent label contents considered as the input label. Calling `.checkboxradio( "refresh" )` on such a widget and the initial HTML contained encoded HTML entities will make them erroneously get decoded. This can lead to potentially executing JavaScript code. The bug has been patched in jQuery UI 1.13.2. To remediate the issue, someone who can change the initial HTML can wrap all the non-input contents of the `label` in a `span`.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2022-07-20T20:15Z
Repository: https://github.com/jquery/jquery-ui
Language: JavaScript
Files changed: tests/unit/checkboxradio/checkboxradio.html, tests/unit/checkboxradio/core.js, tests/unit/checkboxradio/methods.js, ui/widgets/checkboxradio.js
Commit date: 2022-07-14 20:52:02 +0200
Commit message: Checkboxradio: Don't re-evaluate text labels as HTML

If you generate a Checkboxradio from a checkbox/radio with a label that
contains encoded HTML, e.g. `&lt;em&gt;test&lt;/em&gt;` this will work fine
at first. If, however a refresh is triggered on that instance (explicitly or
e.g. by turning it into a `Controlgroup`), the previously escaped HTML will
now be evaluated.

If the label was created based on some user input, this could lead to
unexpected code execution even though the initial output was escaped.

Fixes gh-2101
Closes gh-2102
Diff: diff --git a/tests/unit/checkboxradio/checkboxradio.html b/tests/unit/checkboxradio/checkboxradio.html
index 9883e0834..62552fefc 100644
--- a/tests/unit/checkboxradio/checkboxradio.html
+++ b/tests/unit/checkboxradio/checkboxradio.html
@@ -62,10 +62,22 @@
 <input type="checkbox" class="foo" id="checkbox-option-icon"/>
 <label for="checkbox-option-label">checkbox label<input type="checkbox" class="foo" id="checkbox-option-label"/></label>
 <label>
 	<input type="checkbox" id="label-with-no-for"/>
 </label>
+<label>
+	<input type="checkbox" id="label-with-no-for-with-html"/>
+	<strong>Hi</strong>, <em>I'm a label</em>
+</label>
+<label>
+	<input type="checkbox" id="label-with-no-for-with-text"/>
+	Hi, I'm a label
+</label>
+<label>
+	<input type="checkbox" id="label-with-no-for-with-html-like-text"/>
+	&lt;em&gt;Hi, I'm a label&lt;/em&gt;
+</label>
 
 <form id="form3"></form>
 <input type="radio" name="crazy-form" id="crazy-form-1" form="form3" checked="checked">
 <label for="crazy-form-1">Choice 1</label>
 <input type="radio" name="crazy-form" id="crazy-form-2" form="form3">
diff --git a/tests/unit/checkboxradio/core.js b/tests/unit/checkboxradio/core.js
index 8b0e1de8e..ad27f1be0 100644
--- a/tests/unit/checkboxradio/core.js
+++ b/tests/unit/checkboxradio/core.js
@@ -129,6 +129,43 @@ QUnit.test( "Calling checkboxradio on an input with no label throws an error", f
 		error,
 		"Proper error thrown"
 	);
 } );
 
+QUnit.test( "Inheriting label from initial HTML", function( assert ) {
+	var tests = [
+		{
+			id: "label-with-no-for-with-html",
+			expectedLabel: "<strong>Hi</strong>, <em>I'm a label</em>"
+		},
+		{
+			id: "label-with-no-for-with-text",
+			expectedLabel: "Hi, I'm a label"
+		},
+		{
+			id: "label-with-no-for-with-html-like-text",
+			expectedLabel: "&lt;em&gt;Hi, I'm a label&lt;/em&gt;"
+		}
+	];
+
+	assert.expect( tests.length );
+
+	tests.forEach( function( testData ) {
+		var id = testData.id;
+		var expectedLabel = testData.expectedLabel;
+		var inputElem = $( "#" + id );
+		var labelElem = inputElem.parent();
+
+		inputElem.checkboxradio( { icon: false } );
+
+		var labelWithoutInput = labelElem.clone();
+		labelWithoutInput.find( "input" ).remove();
+
+		assert.strictEqual(
+			labelWithoutInput.html().trim(),
+			expectedLabel.trim(),
+			"Label correct [" + id + "]"
+		);
+	} );
+} );
+
 } );
diff --git a/tests/unit/checkboxradio/methods.js b/tests/unit/checkboxradio/methods.js
index 09510ec71..f6c94f94f 100644
--- a/tests/unit/checkboxradio/methods.js
+++ b/tests/unit/checkboxradio/methods.js
@@ -94,6 +94,44 @@ QUnit.test( "Input wrapped in a label preserved on refresh", function( assert )
 
 	input.checkboxradio( "refresh" );
 	assert.strictEqual( input.parent()[ 0 ], element[ 0 ], "Input preserved" );
 } );
 
+QUnit.test( "Initial text label not turned to HTML on refresh", function( assert ) {
+	var tests = [
+		{
+			id: "label-with-no-for-with-html",
+			expectedLabel: "<strong>Hi</strong>, <em>I'm a label</em>"
+		},
+		{
+			id: "label-with-no-for-with-text",
+			expectedLabel: "Hi, I'm a label"
+		},
+		{
+			id: "label-with-no-for-with-html-like-text",
+			expectedLabel: "&lt;em&gt;Hi, I'm a label&lt;/em&gt;"
+		}
+	];
+
+	assert.expect( tests.length );
+
+	tests.forEach( function( testData ) {
+		var id = testData.id;
+		var expectedLabel = testData.expectedLabel;
+		var inputElem = $( "#" + id );
+		var labelElem = inputElem.parent();
+
+		inputElem.checkboxradio( { icon: false } );
+		inputElem.checkboxradio( "refresh" );
+
+		var labelWithoutInput = labelElem.clone();
+		labelWithoutInput.find( "input" ).remove();
+
+		assert.strictEqual(
+			labelWithoutInput.html().trim(),
+			expectedLabel.trim(),
+			"Label correct [" + id + "]"
+		);
+	} );
+} );
+
 } );
diff --git a/ui/widgets/checkboxradio.js b/ui/widgets/checkboxradio.js
index b2537d292..afa4a446d 100644
--- a/ui/widgets/checkboxradio.js
+++ b/ui/widgets/checkboxradio.js
@@ -48,12 +48,11 @@ $.widget( "ui.checkboxradio", [ $.ui.formResetMixin, {
 			"ui-checkboxradio-icon": "ui-corner-all"
 		}
 	},
 
 	_getCreateOptions: function() {
-		var disabled, labels;
-		var that = this;
+		var disabled, labels, labelContents;
 		var options = this._super() || {};
 
 		// We read the type here, because it makes more sense to throw a element type error first,
 		// rather then the error for lack of a label. Often if its the wrong type, it
 		// won't have a label (e.g. calling on a div, btn, etc)
@@ -69,16 +68,22 @@ $.widget( "ui.checkboxradio", [ $.ui.formResetMixin, {
 
 		this.originalLabel = "";
 
 		// We need to get the label text but this may also need to make sure it does not contain the
 		// input itself.
-		this.label.contents().not( this.element[ 0 ] ).each( function() {
-
-			// The label contents could be text, html, or a mix. We concat each element to get a
-			// string representation of the label, without the input as part of it.
-			that.originalLabel += this.nodeType === 3 ? $( this ).text() : this.outerHTML;
-		} );
+		// The label contents could be text, html, or a mix. We wrap all elements
+		// and read the wrapper's `innerHTML` to get a string representation of
+		// the label, without the input as part of it.
+		labelContents = this.label.contents().not( this.element[ 0 ] );
+
+		if ( labelContents.length ) {
+			this.originalLabel += labelContents
+				.clone()
+				.wrapAll( "<div></div>" )
+				.parent()
+				.html();
+		}
 
 		// Set the label option if we found label text
 		if ( this.originalLabel ) {
 			options.label = this.originalLabel;
 		}

Vulnerable code: 		var disabled, labels;
		var that = this;
		this.label.contents().not( this.element[ 0 ] ).each( function() {

			// The label contents could be text, html, or a mix. We concat each element to get a
			// string representation of the label, without the input as part of it.
			that.originalLabel += this.nodeType === 3 ? $( this ).text() : this.outerHTML;
		} );
Fixed code: <label>
	<input type="checkbox" id="label-with-no-for-with-html"/>
	<strong>Hi</strong>, <em>I'm a label</em>
</label>
<label>
	<input type="checkbox" id="label-with-no-for-with-text"/>
	Hi, I'm a label
</label>
<label>
	<input type="checkbox" id="label-with-no-for-with-html-like-text"/>
	&lt;em&gt;Hi, I'm a label&lt;/em&gt;
</label>
QUnit.test( "Inheriting label from initial HTML", function( assert ) {
	var tests = [
		{
			id: "label-with-no-for-with-html",
			expectedLabel: "<strong>Hi</strong>, <em>I'm a label</em>"
		},
		{
			id: "label-with-no-for-with-text",
			expectedLabel: "Hi, I'm a label"
		},
		{
			id: "label-with-no-for-with-html-like-text",
			expectedLabel: "&lt;em&gt;Hi, I'm a label&lt;/em&gt;"
		}
	];

	assert.expect( tests.length );

	tests.forEach( function( testData ) {
		var id = testData.id;
		var expectedLabel = testData.expectedLabel;
		var inputElem = $( "#" + id );
		var labelElem = inputElem.parent();

		inputElem.checkboxradio( { icon: false } );

		var labelWithoutInput = labelElem.clone();
		labelWithoutInput.find( "input" ).remove();

		assert.strictEqual(
			labelWithoutInput.html().trim(),
			expectedLabel.trim(),
			"Label correct [" + id + "]"
		);
	} );
} );

QUnit.test( "Initial text label not turned to HTML on refresh", function( assert ) {
	var tests = [
		{
			id: "label-with-no-for-with-html",
			expectedLabel: "<strong>Hi</strong>, <em>I'm a label</em>"
		},
		{
			id: "label-with-no-for-with-text",
			expectedLabel: "Hi, I'm a label"
		},
		{
			id: "label-with-no-for-with-html-like-text",
			expectedLabel: "&lt;em&gt;Hi, I'm a label&lt;/em&gt;"
		}
	];

	assert.expect( tests.length );

	tests.forEach( function( testData ) {
		var id = testData.id;
		var expectedLabel = testData.expectedLabel;
		var inputElem = $( "#" + id );
		var labelElem = inputElem.parent();

		inputElem.checkboxradio( { icon: false } );
		inputElem.checkboxradio( "refresh" );

		var labelWithoutInput = labelElem.clone();
		labelWithoutInput.find( "input" ).remove();

		assert.strictEqual(
			labelWithoutInput.html().trim(),
			expectedLabel.trim(),
			"Label correct [" + id + "]"
		);
	} );
} );

		var disabled, labels, labelContents;
		// The label contents could be text, html, or a mix. We wrap all elements
		// and read the wrapper's `innerHTML` to get a string representation of
		// the label, without the input as part of it.
		labelContents = this.label.contents().not( this.element[ 0 ] );

		if ( labelContents.length ) {
			this.originalLabel += labelContents
				.clone()
				.wrapAll( "<div></div>" )
				.parent()
				.html();
		}