VYPR
High severityOSV Advisory· Published Dec 27, 2025· Updated Dec 29, 2025

Improper Neutralization of HTML Tags in a Web Page in libredesk

CVE-2025-68927

Description

Libredesk is a self-hosted customer support desk. Prior to version 0.8.6-beta, LibreDesk is vulnerable to stored HTML injection in the contact notes feature. When adding notes via POST /api/v1/contacts/{id}/notes, the backend automatically wraps user input in <p> tags. However, by intercepting the request and removing the <p> tag, an attacker can inject arbitrary HTML elements such as forms and images, which are then stored and rendered without proper sanitization. This can lead to phishing, CSRF-style forced actions, and UI redress attacks. This issue has been patched in version 0.8.6-beta.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/abhinavxd/libredeskGo
< 0.8.6-beta0.8.6-beta

Affected products

1

Patches

1
270347849943

fix: use vue-letter to render html in contact notes, command box. This prevents forms from being injected in contact notes, macros etc.

https://github.com/abhinavxd/libredeskAbhinav RautDec 15, 2025via ghsa
9 files changed · +234 352
  • frontend/package.json+0 1 modified
    @@ -53,7 +53,6 @@
         "tailwind-merge": "^2.3.0",
         "vee-validate": "^4.15.0",
         "vue": "^3.4.37",
    -    "vue-dompurify-html": "^5.2.0",
         "vue-i18n": "9",
         "vue-letter": "^0.2.0",
         "vue-picture-cropper": "^0.7.0",
    
  • frontend/pnpm-lock.yaml+0 26 modified
    @@ -113,9 +113,6 @@ importers:
           vue:
             specifier: ^3.4.37
             version: 3.5.13(typescript@5.7.3)
    -      vue-dompurify-html:
    -        specifier: ^5.2.0
    -        version: 5.2.0(vue@3.5.13(typescript@5.7.3))
           vue-i18n:
             specifier: '9'
             version: 9.14.5(vue@3.5.13(typescript@5.7.3))
    @@ -1209,9 +1206,6 @@ packages:
       '@types/topojson@3.2.6':
         resolution: {integrity: sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==}
     
    -  '@types/trusted-types@2.0.7':
    -    resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
    -
       '@types/web-bluetooth@0.0.20':
         resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
     
    @@ -1912,9 +1906,6 @@ packages:
         resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
         engines: {node: '>=6.0.0'}
     
    -  dompurify@3.2.4:
    -    resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
    -
       dunder-proto@1.0.1:
         resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
         engines: {node: '>= 0.4'}
    @@ -3447,11 +3438,6 @@ packages:
           '@vue/composition-api':
             optional: true
     
    -  vue-dompurify-html@5.2.0:
    -    resolution: {integrity: sha512-GX+BStkKEJ8wu/+hU1EK2nu/gzXWhb4XzBu6aowpsuU/3nkvXvZ2jx4nZ9M3jtS/Vu7J7MtFXjc7x3cWQ+zbVQ==}
    -    peerDependencies:
    -      vue: ^3.0.0
    -
       vue-eslint-parser@9.4.3:
         resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
         engines: {node: ^14.17.0 || >=16.0.0}
    @@ -4611,9 +4597,6 @@ snapshots:
           '@types/topojson-simplify': 3.0.3
           '@types/topojson-specification': 1.0.5
     
    -  '@types/trusted-types@2.0.7':
    -    optional: true
    -
       '@types/web-bluetooth@0.0.20': {}
     
       '@types/web-bluetooth@0.0.21': {}
    @@ -5449,10 +5432,6 @@ snapshots:
         dependencies:
           esutils: 2.0.3
     
    -  dompurify@3.2.4:
    -    optionalDependencies:
    -      '@types/trusted-types': 2.0.7
    -
       dunder-proto@1.0.1:
         dependencies:
           call-bind-apply-helpers: 1.0.2
    @@ -7086,11 +7065,6 @@ snapshots:
         dependencies:
           vue: 3.5.13(typescript@5.7.3)
     
    -  vue-dompurify-html@5.2.0(vue@3.5.13(typescript@5.7.3)):
    -    dependencies:
    -      dompurify: 3.2.4
    -      vue: 3.5.13(typescript@5.7.3)
    -
       vue-eslint-parser@9.4.3(eslint@8.57.1):
         dependencies:
           debug: 4.4.0(supports-color@8.1.1)
    
  • frontend/src/features/command/CommandBox.vue+4 2 modified
    @@ -71,9 +71,10 @@
                         <p class="text-xs font-semibold text-foreground">
                           {{ $t('command.replyPreview') }}
                         </p>
    -                    <div
    +                    <Letter
    +                      :html="replyContent"
    +                      :allowedSchemas="['cid', 'https', 'http', 'mailto']"
                           class="w-full min-h-200 p-2 bg-muted/50 rounded overflow-auto shadow native-html"
    -                      v-dompurify-html="replyContent"
                         />
                       </div>
     
    @@ -237,6 +238,7 @@ import { Calendar } from '@/components/ui/calendar'
     import { Input } from '@/components/ui/input'
     import { Label } from '@/components/ui/label'
     import { useI18n } from 'vue-i18n'
    +import { Letter } from 'vue-letter'
     
     const conversationStore = useConversationStore()
     const macroStore = useMacroStore()
    
  • frontend/src/features/contact/ContactNotes.vue+6 1 modified
    @@ -99,7 +99,11 @@
     
             <!-- Note content -->
             <CardContent class="pt-4 pb-5">
    -          <p class="whitespace-pre-wrap text-sm native-html" v-dompurify-html="note.note"></p>
    +          <Letter
    +            :html="note.note"
    +            :allowedSchemas="['cid', 'https', 'http', 'mailto']"
    +            class="whitespace-pre-wrap text-sm native-html"
    +          />
             </CardContent>
           </Card>
         </div>
    @@ -155,6 +159,7 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
     import { handleHTTPError } from '@/utils/http'
     import { getInitials } from '@/utils/strings'
     import { useUserStore } from '@/stores/user'
    +import { Letter } from 'vue-letter'
     import api from '@/api'
     
     const props = defineProps({ contactId: Number })
    
  • frontend/src/features/conversation/message/AgentMessageBubble.vue+0 156 removed
    @@ -1,156 +0,0 @@
    -<template>
    -  <div class="flex flex-col items-end text-left">
    -    <!-- Sender Name -->
    -    <div class="pr-[47px] mb-1">
    -      <p class="text-muted-foreground text-sm font-medium">
    -        {{ getFullName }}
    -      </p>
    -    </div>
    -
    -    <!-- Message Bubble -->
    -    <div class="flex flex-row gap-2 justify-end">
    -      <!-- Bubble Wrapper with max 80% width -->
    -      <div class="w-4/5 flex justify-end">
    -        <div
    -          class="flex flex-col justify-end message-bubble relative"
    -          :class="{
    -            'bg-[#FEF1E1] dark:bg-[#4C3A24]': message.private,
    -            'border border-border': !message.private,
    -            'opacity-50 animate-pulse': message.status === 'pending',
    -            'border-red-400': message.status === 'failed'
    -          }"
    -        >
    -          <!-- Message Envelope -->
    -          <MessageEnvelope :message="message" v-if="showEnvelope" />
    -
    -          <hr class="mb-2" v-if="showEnvelope" />
    -
    -          <!-- Message -->
    -          <div
    -            v-dompurify-html="messageContent"
    -            class="whitespace-pre-wrap break-words overflow-wrap-anywhere native-html"
    -            :class="{ 'mb-3': message.attachments.length > 0 }"
    -          />
    -
    -          <!-- Attachments -->
    -          <MessageAttachmentPreview :attachments="nonInlineAttachments" />
    -
    -          <!-- Spinner for Pending Messages -->
    -          <Spinner v-if="message.status === 'pending'" size="w-4 h-4" />
    -
    -          <!-- Icons -->
    -          <div class="flex items-center space-x-2 mt-2 self-end">
    -            <Lock :size="10" v-if="isPrivateMessage" class="text-muted-foreground" />
    -            <Check :size="14" v-if="showCheckCheck" class="text-green-500" />
    -            <RotateCcw
    -              size="10"
    -              @click="retryMessage(message)"
    -              class="cursor-pointer text-muted-foreground hover:text-foreground transition-colors duration-200"
    -              v-if="showRetry"
    -            />
    -          </div>
    -        </div>
    -      </div>
    -
    -      <!-- Avatar -->
    -      <Avatar class="cursor-pointer w-8 h-8">
    -        <AvatarImage :src="getAvatar" />
    -        <AvatarFallback class="font-medium">
    -          {{ avatarFallback }}
    -        </AvatarFallback>
    -      </Avatar>
    -    </div>
    -
    -    <!-- Timestamp tooltip -->
    -    <div class="pr-[47px]">
    -      <Tooltip>
    -        <TooltipTrigger>
    -          <span class="text-muted-foreground text-xs mt-1">
    -            {{ formatMessageTimestamp(message.created_at) }}
    -          </span>
    -        </TooltipTrigger>
    -        <TooltipContent>
    -          {{ formatFullTimestamp(message.created_at) }}
    -        </TooltipContent>
    -      </Tooltip>
    -    </div>
    -  </div>
    -</template>
    -
    -<script setup>
    -import { computed } from 'vue'
    -import { useConversationStore } from '@/stores/conversation'
    -import { Lock, RotateCcw, Check } from 'lucide-vue-next'
    -import { revertCIDToImageSrc } from '@/utils/strings'
    -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
    -import { Spinner } from '@/components/ui/spinner'
    -import { formatMessageTimestamp, formatFullTimestamp } from '@/utils/datetime'
    -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
    -import MessageAttachmentPreview from '@/features/conversation/message/attachment/MessageAttachmentPreview.vue'
    -import MessageEnvelope from './MessageEnvelope.vue'
    -import api from '@/api'
    -
    -const props = defineProps({
    -  message: Object
    -})
    -const convStore = useConversationStore()
    -
    -const participant = computed(() => {
    -  return convStore.conversation?.participants?.[props.message.sender_id] ?? {}
    -})
    -
    -const getFullName = computed(() => {
    -  const firstName = participant.value?.first_name ?? 'User'
    -  const lastName = participant.value?.last_name ?? ''
    -  return `${firstName} ${lastName}`
    -})
    -
    -const getAvatar = computed(() => {
    -  return participant.value?.avatar_url || ''
    -})
    -
    -const messageContent = computed(() => {
    -  return revertCIDToImageSrc(props.message.content)
    -})
    -
    -const nonInlineAttachments = computed(() =>
    -  props.message.attachments.filter((attachment) => attachment.disposition !== 'inline')
    -)
    -
    -const isPrivateMessage = computed(() => {
    -  return props.message.private
    -})
    -
    -const showCheckCheck = computed(() => {
    -  return props.message.status == 'sent' && !isPrivateMessage.value
    -})
    -
    -const showRetry = computed(() => {
    -  return props.message.status == 'failed'
    -})
    -
    -const avatarFallback = computed(() => {
    -  const firstName = participant.value?.first_name ?? 'A'
    -  return firstName.toUpperCase().substring(0, 2)
    -})
    -
    -const retryMessage = (msg) => {
    -  api.retryMessage(convStore.current.uuid, msg.uuid)
    -}
    -
    -const showEnvelope = computed(() => {
    -  return (
    -    props.message.meta?.from?.length ||
    -    props.message.meta?.to?.length ||
    -    props.message.meta?.cc?.length ||
    -    props.message.meta?.bcc?.length ||
    -    props.message.meta?.subject
    -  )
    -})
    -</script>
    -
    -<style scoped>
    -.overflow-wrap-anywhere {
    -  overflow-wrap: anywhere;
    -}
    -</style>
    
  • frontend/src/features/conversation/message/ContactMessageBubble.vue+0 158 removed
    @@ -1,158 +0,0 @@
    -<template>
    -  <div class="flex flex-col items-start">
    -    <!-- Sender Name -->
    -    <div class="pl-[47px] mb-1">
    -      <p class="text-muted-foreground text-sm font-medium">
    -        {{ getFullName }}
    -      </p>
    -    </div>
    -
    -    <!-- Message Bubble -->
    -    <div class="flex flex-row gap-2 w-full">
    -      <!-- Avatar -->
    -      <Avatar class="cursor-pointer w-8 h-8">
    -        <AvatarImage :src="getAvatar" />
    -        <AvatarFallback class="font-medium">
    -          {{ avatarFallback }}
    -        </AvatarFallback>
    -      </Avatar>
    -
    -      <!-- Message Content -->
    -      <div class="w-4/5" :style="'contain: inline-size;'">
    -        <div
    -          class="flex flex-col justify-end message-bubble"
    -          :class="{
    -            'show-quoted-text': showQuotedText,
    -            'hide-quoted-text': !showQuotedText
    -          }"
    -        >
    -          <MessageEnvelope :message="message" v-if="showEnvelope" />
    -
    -          <hr class="mb-2" v-if="showEnvelope" />
    -
    -          <!-- Message Text -->
    -          <div
    -            v-if="message.content_type === 'text'"
    -            class="mb-1 native-html whitespace-pre-wrap"
    -            :class="{ 'mb-3': message.attachments.length > 0 }"
    -          >
    -            {{ sanitizedMessageContent }}
    -          </div>
    -          <Letter
    -            v-else
    -            :html="sanitizedMessageContent"
    -            :allowedSchemas="['cid', 'https', 'http', 'mailto']"
    -            class="mb-1 native-html"
    -            :class="{ 'mb-3': message.attachments.length > 0 }"
    -          />
    -
    -          <!-- Quoted Text Toggle -->
    -          <div
    -            v-if="hasQuotedContent"
    -            @click="toggleQuote"
    -            class="text-xs cursor-pointer text-muted-foreground px-2 py-1 w-max hover:bg-muted hover:text-primary rounded transition-all"
    -          >
    -            {{
    -              showQuotedText ? t('conversation.hideQuotedText') : t('conversation.showQuotedText')
    -            }}
    -          </div>
    -
    -          <!-- Attachments -->
    -          <MessageAttachmentPreview :attachments="nonInlineAttachments" />
    -        </div>
    -      </div>
    -    </div>
    -
    -    <!-- Timestamp -->
    -    <div class="pl-[47px]">
    -      <Tooltip>
    -        <TooltipTrigger>
    -          <span class="text-muted-foreground text-xs mt-1">
    -            {{ formatMessageTimestamp(message.created_at) }}
    -          </span>
    -        </TooltipTrigger>
    -        <TooltipContent>
    -          <p>
    -            {{ formatFullTimestamp(message.created_at) }}
    -          </p>
    -        </TooltipContent>
    -      </Tooltip>
    -    </div>
    -  </div>
    -</template>
    -
    -<script setup>
    -import { computed, ref } from 'vue'
    -import { useConversationStore } from '@/stores/conversation'
    -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
    -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
    -import { Letter } from 'vue-letter'
    -import { formatMessageTimestamp, formatFullTimestamp } from '@/utils/datetime'
    -import { useAppSettingsStore } from '@/stores/appSettings'
    -import { useI18n } from 'vue-i18n'
    -import MessageAttachmentPreview from '@/features/conversation/message/attachment/MessageAttachmentPreview.vue'
    -import MessageEnvelope from './MessageEnvelope.vue'
    -
    -const props = defineProps({
    -  message: Object
    -})
    -
    -const convStore = useConversationStore()
    -const settingsStore = useAppSettingsStore()
    -const showQuotedText = ref(false)
    -const { t } = useI18n()
    -
    -const participant = computed(() => {
    -  return convStore.conversation?.participants?.[props.message.sender_id] ?? {}
    -})
    -
    -const getAvatar = computed(() => {
    -  return participant.value?.avatar_url || ''
    -})
    -const sanitizedMessageContent = computed(() => {
    -  let content = props.message.content || ''
    -  const baseUrl = settingsStore.settings['app.root_url']
    -
    -  // Replace CID with URL for inline attachments from the message.
    -  content = props.message.attachments.reduce(
    -    (acc, { content_id, url }) => acc.replace(new RegExp(`cid:${content_id}`, 'g'), url),
    -    content
    -  )
    -
    -  // Add base URL to all img src starting with /uploads/ as vue-letter does not allow relative URLs.
    -  content = content.replace(/src="\/uploads\//g, `src="${baseUrl}/uploads/`)
    -
    -  return content
    -})
    -
    -const hasQuotedContent = computed(() => sanitizedMessageContent.value.includes('<blockquote'))
    -
    -const toggleQuote = () => {
    -  showQuotedText.value = !showQuotedText.value
    -}
    -
    -const nonInlineAttachments = computed(() =>
    -  props.message.attachments.filter((attachment) => attachment.disposition !== 'inline')
    -)
    -
    -const getFullName = computed(() => {
    -  const firstName = participant.value?.first_name ?? 'User'
    -  const lastName = participant.value?.last_name ?? ''
    -  return `${firstName} ${lastName}`
    -})
    -
    -const avatarFallback = computed(() => {
    -  const firstName = participant.value?.first_name ?? 'U'
    -  return firstName.toUpperCase().substring(0, 2)
    -})
    -
    -const showEnvelope = computed(() => {
    -  return (
    -    props.message.meta?.from?.length ||
    -    props.message.meta?.to?.length ||
    -    props.message.meta?.cc?.length ||
    -    props.message.meta?.bcc?.length ||
    -    props.message.meta?.subject
    -  )
    -})
    -</script>
    
  • frontend/src/features/conversation/message/MessageBubble.vue+220 0 added
    @@ -0,0 +1,220 @@
    +<template>
    +  <div class="flex flex-col text-left" :class="isOutgoing ? 'items-end' : 'items-start'">
    +    <!-- Sender Name -->
    +    <div class="mb-1" :class="isOutgoing ? 'pr-[47px]' : 'pl-[47px]'">
    +      <p class="text-muted-foreground text-sm font-medium">
    +        {{ getFullName }}
    +      </p>
    +    </div>
    +
    +    <!-- Message Bubble -->
    +    <div class="flex flex-row gap-2 w-full" :class="{ 'justify-end': isOutgoing }">
    +      <!-- Avatar (left for incoming) -->
    +      <Avatar v-if="!isOutgoing" class="cursor-pointer w-8 h-8">
    +        <AvatarImage :src="getAvatar" />
    +        <AvatarFallback class="font-medium">
    +          {{ avatarFallback }}
    +        </AvatarFallback>
    +      </Avatar>
    +
    +      <!-- Bubble Wrapper with max 80% width -->
    +      <div
    +        class="w-4/5"
    +        :class="{ 'flex justify-end': isOutgoing }"
    +        style="contain: inline-size"
    +      >
    +        <div
    +          class="flex flex-col justify-end message-bubble"
    +          :class="bubbleClasses"
    +        >
    +          <!-- Message Envelope -->
    +          <MessageEnvelope :message="message" v-if="showEnvelope" />
    +
    +          <hr class="mb-2" v-if="showEnvelope" />
    +
    +          <!-- Message Content -->
    +          <div
    +            v-if="message.content_type === 'text'"
    +            class="mb-1 native-html whitespace-pre-wrap"
    +            :class="{ 'mb-3': message.attachments.length > 0 }"
    +          >
    +            {{ sanitizedContent }}
    +          </div>
    +          <Letter
    +            v-else
    +            :html="sanitizedContent"
    +            :allowedSchemas="['cid', 'https', 'http', 'mailto']"
    +            class="mb-1 native-html whitespace-pre-wrap break-words"
    +            :class="{ 'mb-3': message.attachments.length > 0 }"
    +          />
    +
    +          <!-- Quoted Text Toggle (incoming only) -->
    +          <div
    +            v-if="!isOutgoing && hasQuotedContent"
    +            @click="toggleQuote"
    +            class="text-xs cursor-pointer text-muted-foreground px-2 py-1 w-max hover:bg-muted hover:text-primary rounded transition-all"
    +          >
    +            {{ showQuotedText ? t('conversation.hideQuotedText') : t('conversation.showQuotedText') }}
    +          </div>
    +
    +          <!-- Attachments -->
    +          <MessageAttachmentPreview :attachments="nonInlineAttachments" />
    +
    +          <!-- Spinner for Pending Messages (outgoing only) -->
    +          <Spinner v-if="isOutgoing && message.status === 'pending'" size="w-4 h-4" />
    +
    +          <!-- Status Icons (outgoing only) -->
    +          <div v-if="isOutgoing" class="flex items-center space-x-2 mt-2 self-end">
    +            <Lock :size="10" v-if="isPrivateMessage" class="text-muted-foreground" />
    +            <Check :size="14" v-if="showCheckCheck" class="text-green-500" />
    +            <RotateCcw
    +              size="10"
    +              @click="retryMessage(message)"
    +              class="cursor-pointer text-muted-foreground hover:text-foreground transition-colors duration-200"
    +              v-if="showRetry"
    +            />
    +          </div>
    +        </div>
    +      </div>
    +
    +      <!-- Avatar (right for outgoing) -->
    +      <Avatar v-if="isOutgoing" class="cursor-pointer w-8 h-8">
    +        <AvatarImage :src="getAvatar" />
    +        <AvatarFallback class="font-medium">
    +          {{ avatarFallback }}
    +        </AvatarFallback>
    +      </Avatar>
    +    </div>
    +
    +    <!-- Timestamp tooltip -->
    +    <div :class="isOutgoing ? 'pr-[47px]' : 'pl-[47px]'">
    +      <Tooltip>
    +        <TooltipTrigger>
    +          <span class="text-muted-foreground text-xs mt-1">
    +            {{ formatMessageTimestamp(message.created_at) }}
    +          </span>
    +        </TooltipTrigger>
    +        <TooltipContent>
    +          <p>{{ formatFullTimestamp(message.created_at) }}</p>
    +        </TooltipContent>
    +      </Tooltip>
    +    </div>
    +  </div>
    +</template>
    +
    +<script setup>
    +import { computed, ref } from 'vue'
    +import { useConversationStore } from '@/stores/conversation'
    +import { useAppSettingsStore } from '@/stores/appSettings'
    +import { useI18n } from 'vue-i18n'
    +import { Lock, RotateCcw, Check } from 'lucide-vue-next'
    +import { revertCIDToImageSrc } from '@/utils/strings'
    +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
    +import { Spinner } from '@/components/ui/spinner'
    +import { formatMessageTimestamp, formatFullTimestamp } from '@/utils/datetime'
    +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
    +import { Letter } from 'vue-letter'
    +import MessageAttachmentPreview from '@/features/conversation/message/attachment/MessageAttachmentPreview.vue'
    +import MessageEnvelope from './MessageEnvelope.vue'
    +import api from '@/api'
    +
    +const props = defineProps({
    +  message: Object,
    +  direction: {
    +    type: String,
    +    validator: (v) => ['incoming', 'outgoing'].includes(v)
    +  }
    +})
    +
    +const convStore = useConversationStore()
    +const settingsStore = useAppSettingsStore()
    +const { t } = useI18n()
    +
    +// Direction helpers
    +const isOutgoing = computed(() => props.direction === 'outgoing')
    +
    +// Participant info
    +const participant = computed(() => {
    +  return convStore.conversation?.participants?.[props.message.sender_id] ?? {}
    +})
    +
    +const getFullName = computed(() => {
    +  const firstName = participant.value?.first_name ?? 'User'
    +  const lastName = participant.value?.last_name ?? ''
    +  return `${firstName} ${lastName}`
    +})
    +
    +const getAvatar = computed(() => {
    +  return participant.value?.avatar_url || ''
    +})
    +
    +const avatarFallback = computed(() => {
    +  const firstName = participant.value?.first_name ?? (isOutgoing.value ? 'A' : 'U')
    +  return firstName.toUpperCase().substring(0, 2)
    +})
    +
    +// Content sanitization - different processing for incoming vs outgoing
    +const sanitizedContent = computed(() => {
    +  let content = props.message.content || ''
    +
    +  if (isOutgoing.value) {
    +    return revertCIDToImageSrc(content)
    +  } else {
    +    const baseUrl = settingsStore.settings['app.root_url']
    +    content = props.message.attachments.reduce(
    +      (acc, { content_id, url }) => acc.replace(new RegExp(`cid:${content_id}`, 'g'), url),
    +      content
    +    )
    +    content = content.replace(/src="\/uploads\//g, `src="${baseUrl}/uploads/`)
    +    return content
    +  }
    +})
    +
    +const nonInlineAttachments = computed(() =>
    +  props.message.attachments.filter((attachment) => attachment.disposition !== 'inline')
    +)
    +
    +// Bubble classes - conditional based on direction
    +const bubbleClasses = computed(() => ({
    +  // Outgoing-specific: private message styling
    +  'bg-[#FEF1E1] dark:bg-[#4C3A24]': isOutgoing.value && props.message.private,
    +  'border border-border': isOutgoing.value && !props.message.private,
    +  'opacity-50 animate-pulse': isOutgoing.value && props.message.status === 'pending',
    +  'border-red-400': isOutgoing.value && props.message.status === 'failed',
    +  relative: isOutgoing.value,
    +  // Incoming-specific: quoted text visibility
    +  'show-quoted-text': !isOutgoing.value && showQuotedText.value,
    +  'hide-quoted-text': !isOutgoing.value && !showQuotedText.value
    +}))
    +
    +// Outgoing-only computed properties
    +const isPrivateMessage = computed(() => isOutgoing.value && props.message.private)
    +const showCheckCheck = computed(
    +  () => isOutgoing.value && props.message.status === 'sent' && !isPrivateMessage.value
    +)
    +const showRetry = computed(() => isOutgoing.value && props.message.status === 'failed')
    +
    +const retryMessage = (msg) => {
    +  api.retryMessage(convStore.current.uuid, msg.uuid)
    +}
    +
    +// Incoming-only: quoted text toggle
    +const showQuotedText = ref(false)
    +const hasQuotedContent = computed(
    +  () => !isOutgoing.value && sanitizedContent.value.includes('<blockquote')
    +)
    +const toggleQuote = () => {
    +  showQuotedText.value = !showQuotedText.value
    +}
    +
    +// Envelope visibility (both directions)
    +const showEnvelope = computed(() => {
    +  return (
    +    props.message.meta?.from?.length ||
    +    props.message.meta?.to?.length ||
    +    props.message.meta?.cc?.length ||
    +    props.message.meta?.bcc?.length ||
    +    props.message.meta?.subject
    +  )
    +})
    +</script>
    \ No newline at end of file
    
  • frontend/src/features/conversation/message/MessageList.vue+4 6 modified
    @@ -31,12 +31,11 @@
                   'pt-4': index === 0
                 }"
               >
    -            <div v-if="!message.private">
    -              <ContactMessageBubble :message="message" v-if="message.type === 'incoming'" />
    -              <AgentMessageBubble :message="message" v-if="message.type === 'outgoing'" />
    +            <div v-if="!message.private && message.type !== 'activity'">
    +              <MessageBubble :message="message" :direction="message.type" />
                 </div>
                 <div v-else-if="isPrivateNote(message)">
    -              <AgentMessageBubble :message="message" v-if="message.type === 'outgoing'" />
    +              <MessageBubble :message="message" direction="outgoing" />
                 </div>
                 <div v-else-if="message.type === 'activity'">
                   <ActivityMessageBubble :message="message" />
    @@ -75,9 +74,8 @@
     
     <script setup>
     import { ref, onMounted, watch } from 'vue'
    -import ContactMessageBubble from './ContactMessageBubble.vue'
    +import MessageBubble from './MessageBubble.vue'
     import ActivityMessageBubble from './ActivityMessageBubble.vue'
    -import AgentMessageBubble from './AgentMessageBubble.vue'
     import { useConversationStore } from '@/stores/conversation'
     import { useUserStore } from '@/stores/user'
     import { Button } from '@/components/ui/button'
    
  • frontend/src/main.js+0 2 modified
    @@ -7,7 +7,6 @@ import mitt from 'mitt'
     import api from './api'
     import './assets/styles/main.scss'
     import './utils/strings.js'
    -import VueDOMPurifyHTML from 'vue-dompurify-html'
     import Root from './Root.vue'
     
     const setFavicon = (url) => {
    @@ -59,7 +58,6 @@ async function initApp () {
     
       app.use(router)
       app.use(i18n)
    -  app.use(VueDOMPurifyHTML)
       app.mount('#app')
     }
     
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.