CVE-2022-25875: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: The package svelte before 3.49.0 are vulnerable to Cross-site Scripting (XSS) due to improper input sanitization and to improper escape of attributes when using objects during SSR (Server-Side Rendering). Exploiting this vulnerability is possible via objects with a custom toString() function.
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-12T19:15Z
Repository: https://github.com/sveltejs/svelte
Language: Other
Files changed: src/runtime/internal/ssr.ts, test/server-side-rendering/samples/attribute-escaped-quotes-spread/_expected.html, test/server-side-rendering/samples/attribute-escaped-quotes-spread/main.svelte
Commit date: 2022-06-20 13:02:48 +0200
Commit message: [fix] harden attribute escaping during ssr (#7530)


Diff: diff --git a/src/runtime/internal/ssr.ts b/src/runtime/internal/ssr.ts
index 2c7d24a2c..dc4c16dff 100644
--- a/src/runtime/internal/ssr.ts
+++ b/src/runtime/internal/ssr.ts
@@ -83,22 +83,24 @@ export function escape(value: unknown, is_attr = false) {
 	pattern.lastIndex = 0;
 
 	let escaped = '';
 	let last = 0;
 
-  while (pattern.test(str)) {
-    const i = pattern.lastIndex - 1;
-    const ch = str[i];
-    escaped += str.substring(last, i) + (ch === '&' ? '&amp;' : (ch === '"' ? '&quot;' : '&lt;'));
-    last = i + 1;
-  }
+	while (pattern.test(str)) {
+		const i = pattern.lastIndex - 1;
+		const ch = str[i];
+		escaped += str.substring(last, i) + (ch === '&' ? '&amp;' : (ch === '"' ? '&quot;' : '&lt;'));
+		last = i + 1;
+	}
 
 	return escaped + str.substring(last);
 }
 
 export function escape_attribute_value(value) {
-	return typeof value === 'string' ? escape(value, true) : value;
+	// keep booleans, null, and undefined for the sake of `spread`
+	const should_escape = typeof value === 'string' || (value && typeof value === 'object');
+	return should_escape ? escape(value, true) : value;
 }
 
 export function escape_object(obj) {
 	const result = {};
 	for (const key in obj) {
@@ -190,11 +192,11 @@ export function create_ssr_component(fn) {
 	};
 }
 
 export function add_attribute(name, value, boolean) {
 	if (value == null || (boolean && !value)) return '';
-	const assignment = (boolean && value === true) ? '' : `="${escape_attribute_value(value.toString())}"`;
+	const assignment = (boolean && value === true) ? '' : `="${escape(value, true)}"`;
 	return ` ${name}${assignment}`;
 }
 
 export function add_classes(classes) {
 	return classes ? ` class="${classes}"` : '';
diff --git a/test/server-side-rendering/samples/attribute-escaped-quotes-spread/_expected.html b/test/server-side-rendering/samples/attribute-escaped-quotes-spread/_expected.html
index b3970f88b..5db9a90ff 100644
--- a/test/server-side-rendering/samples/attribute-escaped-quotes-spread/_expected.html
+++ b/test/server-side-rendering/samples/attribute-escaped-quotes-spread/_expected.html
@@ -1,5 +1,11 @@
 <div
 	bar="'></div><script>alert(42)</script>"
 	foo="&quot;></div><script>alert(42)</script>"
 	qux="&amp;&amp;&amp;"
-></div>
\ No newline at end of file
+	quux="&quot;><script>alert(42)</script>"
+></div>
+
+<div
+	foo="foo"
+	unsafe="&quot;><script>alert(42)</script>"
+></div>
diff --git a/test/server-side-rendering/samples/attribute-escaped-quotes-spread/main.svelte b/test/server-side-rendering/samples/attribute-escaped-quotes-spread/main.svelte
index 66047eb09..9d721cd88 100644
--- a/test/server-side-rendering/samples/attribute-escaped-quotes-spread/main.svelte
+++ b/test/server-side-rendering/samples/attribute-escaped-quotes-spread/main.svelte
@@ -1,10 +1,15 @@
 <script>
+	const safe = { foo: 'foo' };
+	const unsafe = { toString: () => '"><script>alert(42)<\/script>' };
+
 	export let props = {
 		foo: '"></div><script>alert(42)</' + 'script>',
 		bar: "'></div><script>alert(42)</" + 'script>',
 		['"></div><script>alert(42)</' + 'script>']: 'baz',
 		qux: '&&&',
+		quux: unsafe
 	};
 </script>
 
-<div {...props}></div>
\ No newline at end of file
+<div {...props}></div>
+<div {...safe} {unsafe}></div>

Vulnerable code:   while (pattern.test(str)) {
    const i = pattern.lastIndex - 1;
    const ch = str[i];
    escaped += str.substring(last, i) + (ch === '&' ? '&amp;' : (ch === '"' ? '&quot;' : '&lt;'));
    last = i + 1;
  }
	return typeof value === 'string' ? escape(value, true) : value;
	const assignment = (boolean && value === true) ? '' : `="${escape_attribute_value(value.toString())}"`;
></div>
<div {...props}></div>
Fixed code: 	while (pattern.test(str)) {
		const i = pattern.lastIndex - 1;
		const ch = str[i];
		escaped += str.substring(last, i) + (ch === '&' ? '&amp;' : (ch === '"' ? '&quot;' : '&lt;'));
		last = i + 1;
	}
	// keep booleans, null, and undefined for the sake of `spread`
	const should_escape = typeof value === 'string' || (value && typeof value === 'object');
	return should_escape ? escape(value, true) : value;
	const assignment = (boolean && value === true) ? '' : `="${escape(value, true)}"`;
	quux="&quot;><script>alert(42)</script>"
></div>

<div
	foo="foo"
	unsafe="&quot;><script>alert(42)</script>"
></div>
	const safe = { foo: 'foo' };
	const unsafe = { toString: () => '"><script>alert(42)<\/script>' };

		quux: unsafe
<div {...props}></div>
<div {...safe} {unsafe}></div>