CVE-2023-0410: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Cross-site Scripting (XSS) - Generic in GitHub repository builderio/qwik prior to 0.1.0-beta5.
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: 2023-01-20T01:15Z
Repository: https://github.com/builderio/qwik
Language: Other
Files changed: packages/qwik/src/core/render/ssr/render-ssr.ts, packages/qwik/src/core/render/ssr/render-ssr.unit.tsx
Commit date: 2022-12-19 20:21:56 +0000
Commit message: fix: cleanse ssr attribute name and class value (#2475)

Co-Authored-By: OhB00 <43827372+ohb00@users.noreply.github.com>
Diff: diff --git a/packages/qwik/src/core/render/ssr/render-ssr.ts b/packages/qwik/src/core/render/ssr/render-ssr.ts
index 2234d12a..bca29775 100644
--- a/packages/qwik/src/core/render/ssr/render-ssr.ts
+++ b/packages/qwik/src/core/render/ssr/render-ssr.ts
@@ -542,10 +542,14 @@ const renderNode = (
       if (attrValue != null) {
         if (attrName === 'class') {
           classStr = attrValue;
         } else if (attrName === 'value' && tagName === 'textarea') {
           htmlStr = escapeHtml(attrValue);
+        } else if (isSSRUnsafeAttr(attrName)) {
+          if (qDev) {
+            logError('Attribute value is unsafe for SSR');
+          }
         } else {
           openingElement +=
             ' ' + (value === '' ? attrName : attrName + '="' + escapeAttr(attrValue) + '"');
         }
       }
@@ -629,11 +633,11 @@ This goes against the HTML spec: https://html.spec.whatwg.org/multipage/dom.html
     if (textOnlyElements[tagName]) {
       flags |= IS_TEXT;
     }
 
     if (classStr) {
-      openingElement += ' class="' + classStr + '"';
+      openingElement += ' class="' + escapeAttr(classStr) + '"';
     }
 
     if (listeners.length > 0) {
       const groups = groupListeners(listeners);
       const isInvisible = (flags & IS_INVISIBLE) !== 0;
@@ -1071,10 +1075,16 @@ const escapeAttr = (s: string) => {
         return '';
     }
   });
 };
 
+// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
+const unsafeAttrCharRE = /[>/="'\u0009\u000a\u000c\u0020]/; // eslint-disable-line no-control-regex
+export const isSSRUnsafeAttr = (name: string): boolean => {
+  return unsafeAttrCharRE.test(name);
+};
+
 const listenersNeedId = (listeners: Listener[]) => {
   return listeners.some((l) => l[1].$captureRef$ && l[1].$captureRef$.length > 0);
 };
 
 const addDynamicSlot = (hostCtx: QContext, elCtx: QContext) => {
diff --git a/packages/qwik/src/core/render/ssr/render-ssr.unit.tsx b/packages/qwik/src/core/render/ssr/render-ssr.unit.tsx
index 622e8dc5..62105eb2 100644
--- a/packages/qwik/src/core/render/ssr/render-ssr.unit.tsx
+++ b/packages/qwik/src/core/render/ssr/render-ssr.unit.tsx
@@ -1260,10 +1260,41 @@ renderSSRSuite('null component', async () => {
       <NullCmp />
     </>,
     `<html q:container="paused" q:version="dev" q:render="ssr-dev"><!--qv q:id=0 q:key=sX:--><!--/qv--></html>`
   );
 });
+
+renderSSRSuite('cleanse attribute name', async () => {
+  const o = {
+    '"><script>alert("ಠ~ಠ")</script>': 'xss',
+  };
+  await testSSR(
+    <body {...o}></body>,
+    '<html q:container="paused" q:version="dev" q:render="ssr-dev"><body></body></html>'
+  );
+});
+
+renderSSRSuite('cleanse class attribute', async () => {
+  const o = {
+    class: '"><script>alert("ಠ~ಠ")</script>',
+  };
+  await testSSR(
+    <body {...o}></body>,
+    '<html q:container="paused" q:version="dev" q:render="ssr-dev"><body class="&quot;><script>alert(&quot;ಠ~ಠ&quot;)</script>"></body></html>'
+  );
+});
+
+renderSSRSuite('class emoji valid', async () => {
+  const o = {
+    class: 'package📦',
+  };
+  await testSSR(
+    <body {...o}></body>,
+    '<html q:container="paused" q:version="dev" q:render="ssr-dev"><body class="package📦"></body></html>'
+  );
+});
+
 // TODO
 // Merge props on host
 // - host events
 // - class
 // - style

Vulnerable code:       openingElement += ' class="' + classStr + '"';
Fixed code:         } else if (isSSRUnsafeAttr(attrName)) {
          if (qDev) {
            logError('Attribute value is unsafe for SSR');
          }
      openingElement += ' class="' + escapeAttr(classStr) + '"';
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
const unsafeAttrCharRE = /[>/="'\u0009\u000a\u000c\u0020]/; // eslint-disable-line no-control-regex
export const isSSRUnsafeAttr = (name: string): boolean => {
  return unsafeAttrCharRE.test(name);
};


renderSSRSuite('cleanse attribute name', async () => {
  const o = {
    '"><script>alert("ಠ~ಠ")</script>': 'xss',
  };
  await testSSR(
    <body {...o}></body>,
    '<html q:container="paused" q:version="dev" q:render="ssr-dev"><body></body></html>'
  );
});

renderSSRSuite('cleanse class attribute', async () => {
  const o = {
    class: '"><script>alert("ಠ~ಠ")</script>',
  };
  await testSSR(
    <body {...o}></body>,
    '<html q:container="paused" q:version="dev" q:render="ssr-dev"><body class="&quot;><script>alert(&quot;ಠ~ಠ&quot;)</script>"></body></html>'
  );
});

renderSSRSuite('class emoji valid', async () => {
  const o = {
    class: 'package📦',
  };
  await testSSR(
    <body {...o}></body>,
    '<html q:container="paused" q:version="dev" q:render="ssr-dev"><body class="package📦"></body></html>'
  );
});