CVE-2021-25993: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: In Requarks wiki.js, versions 2.0.0-beta.147 to 2.5.255 are affected by Stored XSS vulnerability, where a low privileged (editor) user can upload a SVG file that contains malicious JavaScript while uploading assets in the page. That will send the JWT tokens to the attacker’s server and will lead to account takeover when accessed by the victim.
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: 5.4
Published: 2021-12-29T17:15Z
Repository: https://github.com/Requarks/wiki
Language: JavaScript
Files changed: client/components/admin/admin-security.vue, server/app/data.yml, server/graph/resolvers/site.js, server/graph/schemas/site.graphql, server/jobs/sanitize-svg.js, server/models/assets.js
Commit date: 2021-12-17 21:41:38 -0500
Commit message: fix: sanitize SVG uploads

Diff: diff --git a/client/components/admin/admin-security.vue b/client/components/admin/admin-security.vue
index d2450039d..ce9c25838 100644
--- a/client/components/admin/admin-security.vue
+++ b/client/components/admin/admin-security.vue
@@ -140,10 +140,19 @@
                     :hint='$t(`admin:security.maxUploadBatchHint`)'
                     persistent-hint
                     :suffix='$t(`admin:security.maxUploadBatchSuffix`)'
                     style='max-width: 450px;'
                     )
+                  v-divider.mt-3
+                  v-switch(
+                    inset
+                    label='Scan and Sanitize SVG Uploads'
+                    color='primary'
+                    v-model='config.uploadScanSVG'
+                    persistent-hint
+                    hint='Should SVG uploads be scanned for vulnerabilities and stripped of any potentially unsafe content.'
+                    )
 
               v-card.mt-3.animated.fadeInUp.wait-p2s
                 v-toolbar(flat, color='primary', dark, dense)
                   .subtitle-1 {{$t('admin:security.login')}}
                 //- v-card-info(color='blue')
@@ -240,10 +249,11 @@ export default {
   data() {
     return {
       config: {
         uploadMaxFileSize: 0,
         uploadMaxFiles: 0,
+        uploadScanSVG: true,
         securityOpenRedirect: true,
         securityIframe: true,
         securityReferrerPolicy: true,
         securityTrustProxy: true,
         securitySRI: true,
@@ -284,10 +294,11 @@ export default {
               $authJwtAudience: String
               $authJwtExpiration: String
               $authJwtRenewablePeriod: String
               $uploadMaxFileSize: Int
               $uploadMaxFiles: Int
+              $uploadScanSVG: Boolean
               $securityOpenRedirect: Boolean
               $securityIframe: Boolean
               $securityReferrerPolicy: Boolean
               $securityTrustProxy: Boolean
               $securitySRI: Boolean
@@ -305,10 +316,11 @@ export default {
                   authJwtAudience: $authJwtAudience,
                   authJwtExpiration: $authJwtExpiration,
                   authJwtRenewablePeriod: $authJwtRenewablePeriod,
                   uploadMaxFileSize: $uploadMaxFileSize,
                   uploadMaxFiles: $uploadMaxFiles,
+                  uploadScanSVG: $uploadScanSVG
                   securityOpenRedirect: $securityOpenRedirect,
                   securityIframe: $securityIframe,
                   securityReferrerPolicy: $securityReferrerPolicy,
                   securityTrustProxy: $securityTrustProxy,
                   securitySRI: $securitySRI,
@@ -335,10 +347,11 @@ export default {
             authJwtAudience: _.get(this.config, 'authJwtAudience', ''),
             authJwtExpiration: _.get(this.config, 'authJwtExpiration', ''),
             authJwtRenewablePeriod: _.get(this.config, 'authJwtRenewablePeriod', ''),
             uploadMaxFileSize: _.toSafeInteger(_.get(this.config, 'uploadMaxFileSize', 0)),
             uploadMaxFiles: _.toSafeInteger(_.get(this.config, 'uploadMaxFiles', 0)),
+            uploadScanSVG: _.get(this.config, 'uploadScanSVG', false),
             securityOpenRedirect: _.get(this.config, 'securityOpenRedirect', false),
             securityIframe: _.get(this.config, 'securityIframe', false),
             securityReferrerPolicy: _.get(this.config, 'securityReferrerPolicy', false),
             securityTrustProxy: _.get(this.config, 'securityTrustProxy', false),
             securitySRI: _.get(this.config, 'securitySRI', false),
@@ -386,10 +399,11 @@ export default {
               authJwtAudience
               authJwtExpiration
               authJwtRenewablePeriod
               uploadMaxFileSize
               uploadMaxFiles
+              uploadScanSVG
               securityOpenRedirect
               securityIframe
               securityReferrerPolicy
               securityTrustProxy
               securitySRI
diff --git a/server/app/data.yml b/server/app/data.yml
index 60f308f50..cb9f2bf6c 100644
--- a/server/app/data.yml
+++ b/server/app/data.yml
@@ -78,10 +78,11 @@ defaults:
     server:
       sslRedir: false
     uploads:
       maxFileSize: 5242880
       maxFiles: 10
+      scanSVG: true
     flags:
       ldapdebug: false
       sqllog: false
     # System defaults
     channel: STABLE
diff --git a/server/graph/resolvers/site.js b/server/graph/resolvers/site.js
index 7b7d4119c..161719fbf 100644
--- a/server/graph/resolvers/site.js
+++ b/server/graph/resolvers/site.js
@@ -27,11 +27,12 @@ module.exports = {
         authLoginBgUrl: WIKI.config.auth.loginBgUrl,
         authJwtAudience: WIKI.config.auth.audience,
         authJwtExpiration: WIKI.config.auth.tokenExpiration,
         authJwtRenewablePeriod: WIKI.config.auth.tokenRenewal,
         uploadMaxFileSize: WIKI.config.uploads.maxFileSize,
-        uploadMaxFiles: WIKI.config.uploads.maxFiles
+        uploadMaxFiles: WIKI.config.uploads.maxFiles,
+        uploadScanSVG: WIKI.config.uploads.scanSVG
       }
     }
   },
   SiteMutation: {
     async updateConfig(obj, args, context) {
@@ -95,11 +96,12 @@ module.exports = {
           securityCSPDirectives: _.get(args, 'securityCSPDirectives', WIKI.config.security.securityCSPDirectives)
         }
 
         WIKI.config.uploads = {
           maxFileSize: _.get(args, 'uploadMaxFileSize', WIKI.config.uploads.maxFileSize),
-          maxFiles: _.get(args, 'uploadMaxFiles', WIKI.config.uploads.maxFiles)
+          maxFiles: _.get(args, 'uploadMaxFiles', WIKI.config.uploads.maxFiles),
+          scanSVG: _.get(args, 'uploadScanSVG', WIKI.config.uploads.scanSVG)
         }
 
         await WIKI.configSvc.saveToDb(['host', 'title', 'company', 'contentLicense', 'seo', 'logoUrl', 'auth', 'features', 'security', 'uploads'])
 
         if (WIKI.config.security.securityTrustProxy) {
diff --git a/server/graph/schemas/site.graphql b/server/graph/schemas/site.graphql
index fcd68f501..65875ac56 100644
--- a/server/graph/schemas/site.graphql
+++ b/server/graph/schemas/site.graphql
@@ -52,45 +52,47 @@ type SiteMutation {
     securityHSTSDuration: Int
     securityCSP: Boolean
     securityCSPDirectives: String
     uploadMaxFileSize: Int
     uploadMaxFiles: Int
+    uploadScanSVG: Boolean
 
   ): DefaultResponse @auth(requires: ["manage:system"])
 }
 
 # -----------------------------------------------
 # TYPES
 # -----------------------------------------------
 
 type SiteConfig {
-  host: String!
-  title: String!
-  description: String!
-  robots: [String]!
-  analyticsService: String!
-  analyticsId: String!
-  company: String!
-  contentLicense: String!
-  logoUrl: String!
+  host: String
+  title: String
+  description: String
+  robots: [String]
+  analyticsService: String
+  analyticsId: String
+  company: String
+  contentLicense: String
+  logoUrl: String
   authAutoLogin: Boolean
   authEnforce2FA: Boolean
   authHideLocal: Boolean
   authLoginBgUrl: String
   authJwtAudience: String
   authJwtExpiration: String
   authJwtRenewablePeriod: String
-  featurePageRatings: Boolean!
-  featurePageComments: Boolean!
-  featurePersonalWikis: Boolean!
-  securityOpenRedirect: Boolean!
-  securityIframe: Boolean!
-  securityReferrerPolicy: Boolean!
-  securityTrustProxy: Boolean!
-  securitySRI: Boolean!
-  securityHSTS: Boolean!
-  securityHSTSDuration: Int!
-  securityCSP: Boolean!
-  securityCSPDirectives: String!
-  uploadMaxFileSize: Int!
-  uploadMaxFiles: Int!
+  featurePageRatings: Boolean
+  featurePageComments: Boolean
+  featurePersonalWikis: Boolean
+  securityOpenRedirect: Boolean
+  securityIframe: Boolean
+  securityReferrerPolicy: Boolean
+  securityTrustProxy: Boolean
+  securitySRI: Boolean
+  securityHSTS: Boolean
+  securityHSTSDuration: Int
+  securityCSP: Boolean
+  securityCSPDirectives: String
+  uploadMaxFileSize: Int
+  uploadMaxFiles: Int
+  uploadScanSVG: Boolean
 }
diff --git a/server/jobs/sanitize-svg.js b/server/jobs/sanitize-svg.js
new file mode 100644
index 000000000..117c20e43
--- /dev/null
+++ b/server/jobs/sanitize-svg.js
@@ -0,0 +1,25 @@
+const fs = require('fs-extra')
+const { JSDOM } = require('jsdom')
+const createDOMPurify = require('dompurify')
+
+/* global WIKI */
+
+module.exports = async (svgPath) => {
+  WIKI.logger.info(`Sanitizing SVG file upload...`)
+
+  try {
+    let svgContents = await fs.readFile(svgPath, 'utf8')
+
+    const window = new JSDOM('').window
+    const DOMPurify = createDOMPurify(window)
+
+    svgContents = DOMPurify.sanitize(svgContents)
+
+    await fs.writeFile(svgPath, svgContents)
+    WIKI.logger.info(`Sanitized SVG file upload: [ COMPLETED ]`)
+  } catch (err) {
+    WIKI.logger.error(`Failed to sanitize SVG file upload: [ FAILED ]`)
+    WIKI.logger.error(err.message)
+    throw err
+  }
+}
diff --git a/server/models/assets.js b/server/models/assets.js
index 8b12060f5..8548319fa 100644
--- a/server/models/assets.js
+++ b/server/models/assets.js
@@ -97,10 +97,20 @@ module.exports = class Asset extends Model {
       mime: opts.mimetype,
       fileSize: opts.size,
       folderId: opts.folderId
     }
 
+    // Sanitize SVG contents
+    if (WIKI.config.uploads.scanSVG && opts.mimetype === 'image/svg+xml') {
+      const svgSanitizeJob = await WIKI.scheduler.registerJob({
+        name: 'sanitize-svg',
+        immediate: true,
+        worker: true
+      }, opts.path)
+      await svgSanitizeJob.finished
+    }
+
     // Save asset data
     try {
       const fileBuffer = await fs.readFile(opts.path)
 
       if (asset) {

Vulnerable code:         uploadMaxFiles: WIKI.config.uploads.maxFiles
          maxFiles: _.get(args, 'uploadMaxFiles', WIKI.config.uploads.maxFiles)
  host: String!
  title: String!
  description: String!
  robots: [String]!
  analyticsService: String!
  analyticsId: String!
  company: String!
  contentLicense: String!
  logoUrl: String!
  featurePageRatings: Boolean!
  featurePageComments: Boolean!
  featurePersonalWikis: Boolean!
  securityOpenRedirect: Boolean!
  securityIframe: Boolean!
  securityReferrerPolicy: Boolean!
  securityTrustProxy: Boolean!
  securitySRI: Boolean!
  securityHSTS: Boolean!
  securityHSTSDuration: Int!
  securityCSP: Boolean!
  securityCSPDirectives: String!
  uploadMaxFileSize: Int!
  uploadMaxFiles: Int!
Fixed code:                   v-divider.mt-3
                  v-switch(
                    inset
                    label='Scan and Sanitize SVG Uploads'
                    color='primary'
                    v-model='config.uploadScanSVG'
                    persistent-hint
                    hint='Should SVG uploads be scanned for vulnerabilities and stripped of any potentially unsafe content.'
                    )
        uploadScanSVG: true,
              $uploadScanSVG: Boolean
                  uploadScanSVG: $uploadScanSVG
            uploadScanSVG: _.get(this.config, 'uploadScanSVG', false),
              uploadScanSVG
      scanSVG: true
        uploadMaxFiles: WIKI.config.uploads.maxFiles,
        uploadScanSVG: WIKI.config.uploads.scanSVG
          maxFiles: _.get(args, 'uploadMaxFiles', WIKI.config.uploads.maxFiles),
          scanSVG: _.get(args, 'uploadScanSVG', WIKI.config.uploads.scanSVG)
    uploadScanSVG: Boolean
  host: String
  title: String
  description: String
  robots: [String]
  analyticsService: String
  analyticsId: String
  company: String
  contentLicense: String
  logoUrl: String
  featurePageRatings: Boolean
  featurePageComments: Boolean
  featurePersonalWikis: Boolean
  securityOpenRedirect: Boolean
  securityIframe: Boolean
  securityReferrerPolicy: Boolean
  securityTrustProxy: Boolean
  securitySRI: Boolean
  securityHSTS: Boolean
  securityHSTSDuration: Int
  securityCSP: Boolean
  securityCSPDirectives: String
  uploadMaxFileSize: Int
  uploadMaxFiles: Int
  uploadScanSVG: Boolean
const fs = require('fs-extra')
const { JSDOM } = require('jsdom')
const createDOMPurify = require('dompurify')

/* global WIKI */

module.exports = async (svgPath) => {
  WIKI.logger.info(`Sanitizing SVG file upload...`)

  try {
    let svgContents = await fs.readFile(svgPath, 'utf8')

    const window = new JSDOM('').window
    const DOMPurify = createDOMPurify(window)

    svgContents = DOMPurify.sanitize(svgContents)

    await fs.writeFile(svgPath, svgContents)
    WIKI.logger.info(`Sanitized SVG file upload: [ COMPLETED ]`)
  } catch (err) {
    WIKI.logger.error(`Failed to sanitize SVG file upload: [ FAILED ]`)
    WIKI.logger.error(err.message)
    throw err
  }
}
    // Sanitize SVG contents
    if (WIKI.config.uploads.scanSVG && opts.mimetype === 'image/svg+xml') {
      const svgSanitizeJob = await WIKI.scheduler.registerJob({
        name: 'sanitize-svg',
        immediate: true,
        worker: true
      }, opts.path)
      await svgSanitizeJob.finished
    }