fix(ops): sécurité + robustesse dispatch/comms

- Sécurité : sanitize DOMPurify sur tout HTML entrant courriel/osTicket (v-html) →
  ferme le vecteur XSS (ConversationPanel + IssueDetail). Nouveau src/utils/sanitize.js,
  dompurify ajouté en dépendance directe.
- Fix : piège TDZ (watches de la carte des tournées placés avant les const suggestDlg/
  suggestMapDay/suggestGroups) qui cassait le montage de PlanificationPage → déplacés
  après les définitions.
- Finition : étiquette de fenêtre du dispatch auto « Aujourd'hui + Demain » (au lieu de l'ISO).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 08:56:35 -04:00
parent 512c4a5f1b
commit 453bef006c
5 changed files with 24 additions and 10 deletions

View File

@ -31,7 +31,8 @@
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",
"vue-email-editor": "^2.2.0", "vue-email-editor": "^2.2.0",
"vue-router": "^4.3.0", "vue-router": "^4.3.0",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0",
"dompurify": "^3.4.11"
}, },
"devDependencies": { "devDependencies": {
"@quasar/app-vite": "^1.10.0", "@quasar/app-vite": "^1.10.0",

View File

@ -352,7 +352,7 @@
<div v-else-if="summaryErr" class="row items-center text-caption text-negative"><q-icon name="error_outline" class="q-mr-xs" />{{ summaryErr }} <q-btn flat dense size="sm" no-caps label="Réessayer" color="deep-purple-6" class="q-ml-sm" @click="summarizeThread" /></div> <div v-else-if="summaryErr" class="row items-center text-caption text-negative"><q-icon name="error_outline" class="q-mr-xs" />{{ summaryErr }} <q-btn flat dense size="sm" no-caps label="Réessayer" color="deep-purple-6" class="q-ml-sm" @click="summarizeThread" /></div>
<div v-else-if="summaryText" class="conv-summary-card"> <div v-else-if="summaryText" class="conv-summary-card">
<div class="row items-center q-mb-xs"><q-icon name="auto_awesome" color="deep-purple-5" size="16px" class="q-mr-xs" /><b class="text-deep-purple-7">Résumé IA</b><q-space /><q-btn flat dense round size="xs" icon="refresh" color="grey-6" @click="summarizeThread"><q-tooltip>Régénérer</q-tooltip></q-btn><q-btn flat dense round size="xs" icon="close" color="grey-6" @click="summaryText = ''" /></div> <div class="row items-center q-mb-xs"><q-icon name="auto_awesome" color="deep-purple-5" size="16px" class="q-mr-xs" /><b class="text-deep-purple-7">Résumé IA</b><q-space /><q-btn flat dense round size="xs" icon="refresh" color="grey-6" @click="summarizeThread"><q-tooltip>Régénérer</q-tooltip></q-btn><q-btn flat dense round size="xs" icon="close" color="grey-6" @click="summaryText = ''" /></div>
<div class="conv-summary-body" v-html="summaryHtml"></div> <div class="conv-summary-body" v-html="sanitizeHtml(summaryHtml)"></div>
<div class="row q-mt-sm"> <div class="row q-mt-sm">
<q-btn dense unelevated no-caps size="sm" color="positive" icon="confirmation_number" :label="'Créer un ticket — ' + ticketTypeLabel" :loading="ticketBusy" @click="createTicketFromSummary" /> <q-btn dense unelevated no-caps size="sm" color="positive" icon="confirmation_number" :label="'Créer un ticket — ' + ticketTypeLabel" :loading="ticketBusy" @click="createTicketFromSummary" />
</div> </div>
@ -421,7 +421,7 @@
<q-checkbox v-if="fwdAttCount" v-model="fwdDialog.includeAtts" dense color="primary" :label="'Inclure les pièces jointes (' + fwdAttCount + ')'" /> <q-checkbox v-if="fwdAttCount" v-model="fwdDialog.includeAtts" dense color="primary" :label="'Inclure les pièces jointes (' + fwdAttCount + ')'" />
<div class="fwd-preview"> <div class="fwd-preview">
<div class="text-caption text-grey-6 q-mb-xs"><q-icon name="subdirectory_arrow_right" size="13px" style="vertical-align:-2px" /> Contenu transféré</div> <div class="text-caption text-grey-6 q-mb-xs"><q-icon name="subdirectory_arrow_right" size="13px" style="vertical-align:-2px" /> Contenu transféré</div>
<div class="fwd-preview-body" v-html="fwdPreviewHtml"></div> <div class="fwd-preview-body" v-html="sanitizeHtml(fwdPreviewHtml)"></div>
</div> </div>
</q-card-section> </q-card-section>
<q-card-actions align="right"> <q-card-actions align="right">
@ -566,7 +566,7 @@
</template> </template>
<q-btn v-else flat dense round size="sm" icon="close" color="green-4" @click="closeOtherDraft"><q-tooltip>Masquer cet aperçu</q-tooltip></q-btn> <q-btn v-else flat dense round size="sm" icon="close" color="green-4" @click="closeOtherDraft"><q-tooltip>Masquer cet aperçu</q-tooltip></q-btn>
</div> </div>
<div class="conv-draft-inline-body" v-html="msg.html || (msg.text || '').replace(/\n/g, '<br>')"></div> <div class="conv-draft-inline-body" v-html="sanitizeHtml(msg.html || (msg.text || '').replace(/\n/g, '<br>'))"></div>
</div> </div>
<div v-else class="conv-msg" :class="'conv-msg-' + msg.from"> <div v-else class="conv-msg" :class="'conv-msg-' + msg.from">
<!-- En-tête repliable (accordéon, style Gmail) : qui · heure · aperçu clic pour déplier/replier --> <!-- En-tête repliable (accordéon, style Gmail) : qui · heure · aperçu clic pour déplier/replier -->
@ -1020,6 +1020,7 @@ import { ref, computed, watch, nextTick, onMounted, onUnmounted, defineAsyncComp
const EmailEditor = defineAsyncComponent(() => import('vue-email-editor').then(m => m.EmailEditor)) const EmailEditor = defineAsyncComponent(() => import('vue-email-editor').then(m => m.EmailEditor))
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { useConversations } from 'src/composables/useConversations' import { useConversations } from 'src/composables/useConversations'
import { sanitizeHtml } from 'src/utils/sanitize'
import { getDoc, listDocs } from 'src/api/erp' import { getDoc, listDocs } from 'src/api/erp'
import RecipientSelect from 'src/components/shared/RecipientSelect.vue' import RecipientSelect from 'src/components/shared/RecipientSelect.vue'
import { useSoftphone } from 'src/composables/useSoftphone' import { useSoftphone } from 'src/composables/useSoftphone'

View File

@ -185,7 +185,7 @@
</div> </div>
<div v-if="doc.resolution_details" class="q-mt-md"> <div v-if="doc.resolution_details" class="q-mt-md">
<div class="info-block-title">Resolution</div> <div class="info-block-title">Resolution</div>
<div class="modal-desc" v-html="doc.resolution_details"></div> <div class="modal-desc" v-html="sanitizeHtml(doc.resolution_details)"></div>
</div> </div>
<div v-if="comms.length" class="q-mt-md"> <div v-if="comms.length" class="q-mt-md">
<div class="info-block-title">Echanges ({{ comms.length }})</div> <div class="info-block-title">Echanges ({{ comms.length }})</div>
@ -193,7 +193,7 @@
<div class="row items-start"> <div class="row items-start">
<div class="col"> <div class="col">
<div class="text-caption text-weight-bold">{{ comm.sender || comm.owner }}</div> <div class="text-caption text-weight-bold">{{ comm.sender || comm.owner }}</div>
<div class="modal-desc q-mt-xs" v-html="comm.content"></div> <div class="modal-desc q-mt-xs" v-html="sanitizeHtml(comm.content)"></div>
</div> </div>
<div class="col-auto text-caption text-grey-6 text-right" style="min-width:90px"> <div class="col-auto text-caption text-grey-6 text-right" style="min-width:90px">
{{ formatDate(comm.creation) }} {{ formatDate(comm.creation) }}
@ -217,7 +217,7 @@
<span class="text-weight-bold">{{ c.comment_by || 'Systeme' }}</span> <span class="text-weight-bold">{{ c.comment_by || 'Systeme' }}</span>
<span class="text-grey-5 q-ml-auto">{{ formatDateTime(c.creation) }}</span> <span class="text-grey-5 q-ml-auto">{{ formatDateTime(c.creation) }}</span>
</div> </div>
<div class="thread-body" v-html="c.content"></div> <div class="thread-body" v-html="sanitizeHtml(c.content)"></div>
</div> </div>
</div> </div>
<div v-if="!doc.description && !doc.resolution_details && !comms.length && !comments.length" class="text-center text-grey-5 q-pa-lg"> <div v-if="!doc.description && !doc.resolution_details && !comms.length && !comments.length" class="text-center text-grey-5 q-pa-lg">
@ -265,6 +265,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { Notify, Dialog } from 'quasar' import { Notify, Dialog } from 'quasar'
import { sanitizeHtml } from 'src/utils/sanitize'
import { formatDate, formatDateTime, erpFileUrl } from 'src/composables/useFormatters' import { formatDate, formatDateTime, erpFileUrl } from 'src/composables/useFormatters'
import { createDoc, updateDoc, deleteDoc, listDocs } from 'src/api/erp' import { createDoc, updateDoc, deleteDoc, listDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth' import { authFetch } from 'src/api/auth'

View File

@ -1009,7 +1009,7 @@
<template v-if="suggestDlg.view === 'map' && suggestWindow.length > 1"> <template v-if="suggestDlg.view === 'map' && suggestWindow.length > 1">
<q-space /> <q-space />
<span class="text-caption text-grey-7">Jour :</span> <span class="text-caption text-grey-7">Jour :</span>
<q-btn-toggle v-model="suggestMapDay" dense no-caps unelevated toggle-color="indigo" color="grey-3" text-color="grey-8" :options="suggestWindow.map(iso => ({ label: fmtDueLabel(iso), value: iso }))" /> <q-btn-toggle v-model="suggestMapDay" dense no-caps unelevated toggle-color="indigo" color="grey-3" text-color="grey-8" :options="suggestWindow.map(iso => ({ label: windowDayLabel(iso), value: iso }))" />
</template> </template>
</div> </div>
<div v-show="suggestDlg.view === 'map'"> <div v-show="suggestDlg.view === 'map'">
@ -3924,10 +3924,11 @@ const suggestWindow = computed(() => {
if (!win.length) win = isos.filter(iso => iso >= t).slice(0, 2) // repli : 2 premiers jours futurs de la grille if (!win.length) win = isos.filter(iso => iso >= t).slice(0, 2) // repli : 2 premiers jours futurs de la grille
return win return win
}) })
const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso))
const suggestWindowLabel = computed(() => { const suggestWindowLabel = computed(() => {
const w = suggestWindow.value; if (!w.length) return '—' const w = suggestWindow.value; if (!w.length) return '—'
if ((assignDateFilter.value || []).length) return w.map(iso => fmtDueLabel(iso)).join(', ') if ((assignDateFilter.value || []).length) return w.map(windowDayLabel).join(', ')
return w.length === 1 ? fmtDueLabel(w[0]) : (fmtDueLabel(w[0]) + ' + ' + fmtDueLabel(w[w.length - 1])) return w.length === 1 ? windowDayLabel(w[0]) : (windowDayLabel(w[0]) + ' + ' + windowDayLabel(w[w.length - 1]))
}) })
function suggestSelectTechs (which) { // 'all' | 'shift' | 'none' function suggestSelectTechs (which) { // 'all' | 'shift' | 'none'
const techs = visibleTechs.value || []; const sel = {} const techs = visibleTechs.value || []; const sel = {}

View File

@ -0,0 +1,10 @@
// Sanitisation du HTML entrant (courriels clients, contenu osTicket/ERPNext) AVANT tout v-html.
// Bloque les vecteurs XSS (<script>, gestionnaires on*, href javascript:) tout en gardant l'affichage riche.
// DOMPurify v3 : config par défaut = sûre ET permissive (conserve img data:/cid réinlinés, liens, tableaux, styles inline).
import DOMPurify from 'dompurify'
export function sanitizeHtml (html) {
if (html == null || html === '') return ''
// ADD_ATTR target → garde target="_blank" sur les liens ; le reste (scripts, on*, javascript:) est retiré par défaut.
return DOMPurify.sanitize(String(html), { ADD_ATTR: ['target'] })
}