CVE-2024-34710: Insufficient Information (NVD-CWE-noinfo)
value: Wiki.js is al wiki app built on Node.js. Client side template injection was discovered, that could allow an attacker to inject malicious JavaScript into the content section of pages that would execute once a victim loads the page that contains the payload. This was possible through the injection of a invalid HTML tag with a template injection payload on the next line. This vulnerability is fixed in 2.5.303.

CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-05-20T22:15Z
Repository: https://github.com/requarks/wiki
Language: JavaScript
Files changed: server/modules/rendering/html-core/renderer.js
Commit date: 2024-05-13 14:57:17 -0400
Commit message: Merge pull request from GHSA-xjcj-p2qv-q3rf

* Update render.js

# Improved handling of mustache expressions and v-pre attribute assignment

## Changes Made:
- Ensured that the parent tag of such text nodes is explicitly set to a `<p>` tag with the `v-pre` attribute.
- Added debug messages for better understanding of the script execution flow [THIS SHOULD REMOVED WHEN PUSHING TO PRODUCTION].

## Why it Works:
- When a mustache expression is found, the script either wraps it in a new `<p>` tag with the `v-pre` attribute or adds the `v-pre` attribute to the existing parent `<p>` tag.
- This approach ensures that the template code is not removed but encapsulated within `<p>` tags with the `v-pre` attribute, as required.

## Test Cases Passed:
1. `<xyz>{{ constructor.constructor('alert(1)')() }}</xyz>`
2. `<xyz>{{ constructor.constructor('alert(1)')() }}</xyz>`
3. `<p><xyz>{{ constructor.constructor('alert(1)')() }}</p>`
4. `<p><xyz>{{ constructor.constructor('alert(1)')() }}</xyz></p>`
5. `<p>&lt;xyz&gt;{{constructor.constructor('alert("Test Case 8")')()}}&lt;xyz&gt;{{constructor.constructor('alert("Test Case 9")')()}}&lt;/xyz&gt;</p>`

This commit enhances the robustness and reliability of handling mustache expressions and ensures proper assignment of the `v-pre` attribute, to ensure that there is no room for the weaponization of the template code later in the rendering process.

* fix: move template expressions after dom-purify + handle text nodes without parent

---------

Co-authored-by: NGPixel <github@ngpixel.com>
Diff: diff --git a/server/modules/rendering/html-core/renderer.js b/server/modules/rendering/html-core/renderer.js
index f0ffbec8..a6426089 100644
--- a/server/modules/rendering/html-core/renderer.js
+++ b/server/modules/rendering/html-core/renderer.js
@@ -8,11 +8,11 @@ const mustacheRegExp = /(\{|&#x7b;?){2}(.+?)(\}|&#x7d;?){2}/i
 
 /* global WIKI */
 
 module.exports = {
   async render() {
-    const $ = cheerio.load(this.input, {
+    let $ = cheerio.load(this.input, {
       decodeEntities: true
     })
 
     if ($.root().children().length < 1) {
       return ''
@@ -251,21 +251,39 @@ module.exports = {
       if (item && item.name === 'table' && item.parent.name === 'body') {
         $(item).wrap('<div class="table-container"></div>')
       }
     })
 
+    // --------------------------------
+    // STEP: POST
+    // --------------------------------
+
+    let output = decodeEscape($.html('body').replace('<body>', '').replace('</body>', ''))
+
+    for (let child of _.sortBy(_.filter(this.children, ['step', 'post']), ['order'])) {
+      const renderer = require(`../${_.kebabCase(child.key)}/renderer.js`)
+      output = await renderer.init(output, child.config)
+    }
+
     // --------------------------------
     // Escape mustache expresions
     // --------------------------------
 
+    $ = cheerio.load(output, {
+      decodeEntities: true
+    })
+
     function iterateMustacheNode (node) {
-      const list = $(node).contents().toArray()
-      list.forEach(item => {
+      $(node).contents().each((idx, item) => {
         if (item && item.type === 'text') {
           const rawText = $(item).text().replace(/\r?\n|\r/g, '')
           if (mustacheRegExp.test(rawText)) {
-            $(item).parent().attr('v-pre', true)
+            if (!item.parent || item.parent.name === 'body') {
+              $(item).wrap($('<p>').attr('v-pre', true))
+            } else {
+              $(item).parent().attr('v-pre', true)
+            }
           }
         } else {
           iterateMustacheNode(item)
         }
       })
@@ -274,22 +292,11 @@ module.exports = {
 
     $('pre').each((idx, elm) => {
       $(elm).attr('v-pre', true)
     })
 
-    // --------------------------------
-    // STEP: POST
-    // --------------------------------
-
-    let output = decodeEscape($.html('body').replace('<body>', '').replace('</body>', ''))
-
-    for (let child of _.sortBy(_.filter(this.children, ['step', 'post']), ['order'])) {
-      const renderer = require(`../${_.kebabCase(child.key)}/renderer.js`)
-      output = await renderer.init(output, child.config)
-    }
-
-    return output
+    return decodeEscape($.html('body').replace('<body>', '').replace('</body>', ''))
   }
 }
 
 function decodeEscape (string) {
   return string.replace(/&#x([0-9a-f]{1,6});/ig, (entity, code) => {

Vulnerable code:     const $ = cheerio.load(this.input, {
      const list = $(node).contents().toArray()
      list.forEach(item => {
            $(item).parent().attr('v-pre', true)
    // --------------------------------
    // STEP: POST
    // --------------------------------

    let output = decodeEscape($.html('body').replace('<body>', '').replace('</body>', ''))

    for (let child of _.sortBy(_.filter(this.children, ['step', 'post']), ['order'])) {
      const renderer = require(`../${_.kebabCase(child.key)}/renderer.js`)
      output = await renderer.init(output, child.config)
    }

    return output
Fixed code:     let $ = cheerio.load(this.input, {
    // --------------------------------
    // STEP: POST
    // --------------------------------

    let output = decodeEscape($.html('body').replace('<body>', '').replace('</body>', ''))

    for (let child of _.sortBy(_.filter(this.children, ['step', 'post']), ['order'])) {
      const renderer = require(`../${_.kebabCase(child.key)}/renderer.js`)
      output = await renderer.init(output, child.config)
    }

    $ = cheerio.load(output, {
      decodeEntities: true
    })

      $(node).contents().each((idx, item) => {
            if (!item.parent || item.parent.name === 'body') {
              $(item).wrap($('<p>').attr('v-pre', true))
            } else {
              $(item).parent().attr('v-pre', true)
            }
    return decodeEscape($.html('body').replace('<body>', '').replace('</body>', ''))