CVE-2021-43842: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Wiki.js is a wiki app built on Node.js. Wiki.js versions 2.5.257 and earlier are vulnerable to stored cross-site scripting through a SVG file upload. By creating a crafted SVG file, a malicious Wiki.js user may stage a stored cross-site scripting attack. This allows the attacker to execute malicious JavaScript when the SVG is viewed directly by other users. Scripts do not execute when loaded inside a page via normal `<img>` tags. Commit 5d3e81496fba1f0fbd64eeb855f30f69a9040718 fixes this vulnerability by adding an optional (enabled by default) SVG sanitization step to all file uploads that match the SVG mime type. As a workaround, disable file upload for all non-trusted users. Wiki.js version 2.5.260 is the first production version to contain a patch. Version 2.5.258 is the first development build to contain a patch and is available only as a Docker image as requarks/wiki:canary-2.5.258.
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-20T23: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
    }