applications: render Application Notes as sanitized HTML
The notes field is authored as HTML (the form says "HTML supported") but the
detail page interpolated it with {{ }}, so tags like <BR> showed as literal
text. Render via v-html through a DOMPurify sanitizer (utils/sanitizeHtml):
allow-list of formatting tags + links only, forces target=_blank
rel=noopener on links, strips scripts/handlers. Promote dompurify to a direct
dependency (was transitive via jspdf).
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
"@fullcalendar/daygrid": "^6.1.20",
|
"@fullcalendar/daygrid": "^6.1.20",
|
||||||
"@fullcalendar/vue3": "^6.1.20",
|
"@fullcalendar/vue3": "^6.1.20",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"dompurify": "^3.4.11",
|
||||||
"jsbarcode": "^3.12.3",
|
"jsbarcode": "^3.12.3",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
|||||||
31
frontend/src/utils/sanitizeHtml.js
Normal file
31
frontend/src/utils/sanitizeHtml.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Safe rendering of user-authored notes HTML (e.g. Application Notes, which the
|
||||||
|
// form advertises as "HTML supported"). DOMPurify strips scripts, event
|
||||||
|
// handlers, and any active/unsafe content; we allow only basic formatting +
|
||||||
|
// links. Never v-html raw notes without this.
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
|
// Force every surviving link to open safely: new tab + no window.opener handle.
|
||||||
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.tagName === 'A' && node.getAttribute('href')) {
|
||||||
|
node.setAttribute('target', '_blank')
|
||||||
|
node.setAttribute('rel', 'noopener noreferrer')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ALLOWED_TAGS = [
|
||||||
|
'p', 'br', 'hr', 'b', 'strong', 'i', 'em', 'u', 's', 'span', 'div',
|
||||||
|
'a', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre',
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||||
|
]
|
||||||
|
const ALLOWED_ATTR = ['href', 'title', 'target', 'rel']
|
||||||
|
|
||||||
|
// Return a sanitized HTML string safe to bind with v-html. Empty in -> empty out.
|
||||||
|
export function sanitizeNotesHtml(html) {
|
||||||
|
if (!html) return ''
|
||||||
|
return DOMPurify.sanitize(String(html), {
|
||||||
|
ALLOWED_TAGS,
|
||||||
|
ALLOWED_ATTR,
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -79,7 +79,9 @@
|
|||||||
<!-- Application Notes -->
|
<!-- Application Notes -->
|
||||||
<div class="section-card" v-if="app.applicationnotes">
|
<div class="section-card" v-if="app.applicationnotes">
|
||||||
<h3 class="section-title">Application Notes</h3>
|
<h3 class="section-title">Application Notes</h3>
|
||||||
<div class="notes-text">{{ app.applicationnotes }}</div>
|
<!-- Notes are authored as HTML (form says "HTML supported");
|
||||||
|
render sanitized so scripts/handlers cannot slip in. -->
|
||||||
|
<div class="notes-text" v-html="sanitizedNotes"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Versions -->
|
<!-- Versions -->
|
||||||
@@ -160,10 +162,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { applicationsApi } from '@/api'
|
import { applicationsApi } from '@/api'
|
||||||
import { getContactEmailDomain } from '@/utils/siteSettings'
|
import { getContactEmailDomain } from '@/utils/siteSettings'
|
||||||
|
import { sanitizeNotesHtml } from '@/utils/sanitizeHtml'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -173,6 +176,9 @@ const versions = ref([])
|
|||||||
const installedOn = ref([])
|
const installedOn = ref([])
|
||||||
const contactEmailDomain = ref('')
|
const contactEmailDomain = ref('')
|
||||||
|
|
||||||
|
// Application notes are HTML; sanitize before binding with v-html.
|
||||||
|
const sanitizedNotes = computed(() => sanitizeNotesHtml(app.value?.applicationnotes))
|
||||||
|
|
||||||
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
|
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
|
||||||
function contactEmail(contact) {
|
function contactEmail(contact) {
|
||||||
return `${contact.sso}@${contactEmailDomain.value}`
|
return `${contact.sso}@${contactEmailDomain.value}`
|
||||||
@@ -418,7 +424,17 @@ function kbKeywords(keywords) {
|
|||||||
|
|
||||||
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
|
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
|
||||||
.notes-text {
|
.notes-text {
|
||||||
white-space: pre-wrap;
|
/* Rendered HTML (v-html): block tags handle spacing, so no pre-wrap. */
|
||||||
|
white-space: normal;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.notes-text :first-child { margin-top: 0; }
|
||||||
|
.notes-text :last-child { margin-bottom: 0; }
|
||||||
|
.notes-text p { margin: 0 0 0.6rem; }
|
||||||
|
.notes-text ul,
|
||||||
|
.notes-text ol { margin: 0 0 0.6rem 1.4rem; }
|
||||||
|
.notes-text a { color: var(--link); }
|
||||||
|
.notes-text code,
|
||||||
|
.notes-text pre { background: var(--bg); border-radius: 4px; padding: 0.1rem 0.3rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user