CVE-2022-25873: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: The package vuetify from 2.0.0-beta.4 and before 2.6.10 are vulnerable to Cross-site Scripting (XSS) due to improper input sanitization in the 'eventName' function within the VCalendar component.
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: 2022-09-18T15:15Z
Repository: https://github.com/vuetifyjs/vuetify
Language: TypeScript
Files changed: packages/vuetify/src/components/VCalendar/mixins/calendar-with-events.ts, packages/vuetify/types/index.d.ts
Commit date: 2022-09-09 00:32:47 +1000
Commit message: fix(VCalendar): prevent XSS from eventName function

fixes #15757

BREAKING CHANGE: eventName can no longer render arbitrary HTML, convert
to VNodes instead

Diff: diff --git a/packages/vuetify/src/components/VCalendar/mixins/calendar-with-events.ts b/packages/vuetify/src/components/VCalendar/mixins/calendar-with-events.ts
index 96e2e5a5e..8692310a3 100644
--- a/packages/vuetify/src/components/VCalendar/mixins/calendar-with-events.ts
+++ b/packages/vuetify/src/components/VCalendar/mixins/calendar-with-events.ts
@@ -8,13 +8,10 @@ import { VNode, VNodeData } from 'vue'
 import ripple from '../../../directives/ripple'
 
 // Mixins
 import CalendarBase from './calendar-base'
 
-// Helpers
-import { escapeHTML } from '../../../util/helpers'
-
 // Util
 import props from '../util/props'
 import {
   CalendarEventOverlapModes,
 } from '../modes'
@@ -114,11 +111,11 @@ export default CalendarBase.extend({
         : () => this.eventTextColor as string
     },
     eventNameFunction (): CalendarEventNameFunction {
       return typeof this.eventName === 'function'
         ? this.eventName
-        : (event, timedEvent) => escapeHTML(event.input[this.eventName as string] as string || '')
+        : (event, timedEvent) => event.input[this.eventName as string] as string || ''
     },
     eventModeFunction (): CalendarEventOverlapMode {
       return typeof this.eventOverlapMode === 'function'
         ? this.eventOverlapMode
         : CalendarEventOverlapModes[this.eventOverlapMode]
@@ -301,20 +298,27 @@ export default CalendarBase.extend({
       const formatTime = this.formatTime
       const timeSummary = () => formatTime(event.start, overlapsNoon) + ' - ' + formatTime(event.end, true)
       const eventSummary = () => {
         const name = this.eventNameFunction(event, timedEvent)
         if (event.start.hasTime) {
-          const eventSummaryClass = 'v-event-summary'
           if (timedEvent) {
             const time = timeSummary()
-            const delimiter = singline ? ', ' : '<br>'
+            const delimiter = singline ? ', ' : this.$createElement('br')
 
-            return `<span class="${eventSummaryClass}"><strong>${name}</strong>${delimiter}${time}</span>`
+            return this.$createElement('span', { staticClass: 'v-event-summary' }, [
+              this.$createElement('strong', [name]),
+              delimiter,
+              time,
+            ])
           } else {
             const time = formatTime(event.start, true)
 
-            return `<span class="${eventSummaryClass}"><strong>${time}</strong> ${name}</span>`
+            return this.$createElement('span', { staticClass: 'v-event-summary' }, [
+              this.$createElement('strong', [time]),
+              ' ',
+              name,
+            ])
           }
         }
 
         return name
       }
@@ -343,17 +347,14 @@ export default CalendarBase.extend({
         ), slot
           ? slot(scope)
           : [this.genName(eventSummary)]
       )
     },
-    genName (eventSummary: () => string): VNode {
+    genName (eventSummary: () => string | VNode): VNode {
       return this.$createElement('div', {
         staticClass: 'pl-1',
-        domProps: {
-          innerHTML: eventSummary(),
-        },
-      })
+      }, [eventSummary()])
     },
     genPlaceholder (day: CalendarTimestamp): VNode {
       const height = this.eventHeight + this.eventMarginBottom
 
       return this.$createElement('div', {
diff --git a/packages/vuetify/types/index.d.ts b/packages/vuetify/types/index.d.ts
index dd26d69d9..27399eded 100644
--- a/packages/vuetify/types/index.d.ts
+++ b/packages/vuetify/types/index.d.ts
@@ -1,6 +1,6 @@
-import Vue, { Component, PluginFunction, VueConstructor, DirectiveOptions } from 'vue'
+import Vue, { Component, PluginFunction, VueConstructor, DirectiveOptions, VNode } from 'vue'
 import './lib'
 import './alacarte'
 import './colors'
 
 // Services
@@ -273,11 +273,11 @@ export type CalendarEventColorFunction = (event: CalendarEvent) => string
 
 export type CalendarEventTimedFunction = (event: CalendarEvent) => boolean
 
 export type CalendarEventCategoryFunction = (event: CalendarEvent) => string
 
-export type CalendarEventNameFunction = (event: CalendarEventParsed, timedEvent: boolean) => string
+export type CalendarEventNameFunction = (event: CalendarEventParsed, timedEvent: boolean) => string | VNode
 
 export type DataTableFilterFunction = (value: any, search: string | null, item: any) => boolean
 
 export interface DataTableHeader<T extends any = any> {
   text: string

Vulnerable code: // Helpers
import { escapeHTML } from '../../../util/helpers'

        : (event, timedEvent) => escapeHTML(event.input[this.eventName as string] as string || '')
          const eventSummaryClass = 'v-event-summary'
            const delimiter = singline ? ', ' : '<br>'
            return `<span class="${eventSummaryClass}"><strong>${name}</strong>${delimiter}${time}</span>`
            return `<span class="${eventSummaryClass}"><strong>${time}</strong> ${name}</span>`
    genName (eventSummary: () => string): VNode {
        domProps: {
          innerHTML: eventSummary(),
        },
      })
import Vue, { Component, PluginFunction, VueConstructor, DirectiveOptions } from 'vue'
export type CalendarEventNameFunction = (event: CalendarEventParsed, timedEvent: boolean) => string
Fixed code:         : (event, timedEvent) => event.input[this.eventName as string] as string || ''
            const delimiter = singline ? ', ' : this.$createElement('br')
            return this.$createElement('span', { staticClass: 'v-event-summary' }, [
              this.$createElement('strong', [name]),
              delimiter,
              time,
            ])
            return this.$createElement('span', { staticClass: 'v-event-summary' }, [
              this.$createElement('strong', [time]),
              ' ',
              name,
            ])
    genName (eventSummary: () => string | VNode): VNode {
      }, [eventSummary()])
import Vue, { Component, PluginFunction, VueConstructor, DirectiveOptions, VNode } from 'vue'
export type CalendarEventNameFunction = (event: CalendarEventParsed, timedEvent: boolean) => string | VNode