From dd89aa49c14850f6f17d145e43732d6f13f1ce0f Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 23 Dec 2025 08:47:15 +0800 Subject: [PATCH] feat: Add HTML and attribute escaping for trigger words and class tokens to prevent XSS vulnerabilities, along with new frontend tests. Fixes #732 --- static/js/components/shared/ModelModal.js | 84 +++---- static/js/components/shared/TriggerWords.js | 211 +++++++++--------- .../components/triggerWords.escaping.test.js | 55 +++++ 3 files changed, 208 insertions(+), 142 deletions(-) create mode 100644 tests/frontend/components/triggerWords.escaping.test.js diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index 84e1dbb3..7e8261b1 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -1,15 +1,15 @@ import { showToast, openCivitai } from '../../utils/uiHelpers.js'; import { modalManager } from '../../managers/ModalManager.js'; -import { +import { toggleShowcase, - setupShowcaseScroll, + setupShowcaseScroll, scrollToTop, loadExampleImages } from './showcase/ShowcaseView.js'; import { setupTabSwitching } from './ModelDescription.js'; -import { - setupModelNameEditing, - setupBaseModelEditing, +import { + setupModelNameEditing, + setupBaseModelEditing, setupFileNameEditing } from './ModelMetadata.js'; import { setupTagEditMode } from './ModelTags.js'; @@ -242,7 +242,7 @@ export async function showModelModal(model, modelType) { const modalTitle = model.model_name; cleanupNavigationShortcuts(); detachModalHandlers(modalId); - + // Fetch complete civitai metadata let completeCivitaiData = model.civitai || {}; if (model.file_path) { @@ -254,7 +254,7 @@ export async function showModelModal(model, modelType) { // Continue with existing data if fetch fails } } - + // Update model with complete civitai data const modelWithFullData = { ...model, @@ -269,14 +269,14 @@ export async function showModelModal(model, modelType) { `.trim() : ''; const creatorInfoAction = modelWithFullData.civitai?.creator ? `
- ${modelWithFullData.civitai.creator.image ? - `
+ ${modelWithFullData.civitai.creator.image ? + `
${modelWithFullData.civitai.creator.username} -
` : - `
+
` : + `
` - } + } ${modelWithFullData.civitai.creator.username}
`.trim() : ''; const creatorActionItems = []; @@ -310,10 +310,10 @@ export async function showModelModal(model, modelType) { const hasUpdateAvailable = Boolean(modelWithFullData.update_available); const updateAvailabilityState = { hasUpdateAvailable }; const updateBadgeTooltip = translate('modelCard.badges.updateAvailable', {}, 'Update available'); - + // Prepare LoRA specific data with complete civitai data - const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ? - modelWithFullData.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : []; + const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ? + modelWithFullData.civitai.trainedWords : []; // Generate model type specific content let typeSpecificContent; @@ -343,7 +343,7 @@ export async function showModelModal(model, modelType) { ${versionsTabBadge} `.trim(); - const tabsContent = modelType === 'loras' ? + const tabsContent = modelType === 'loras' ? ` ${versionsTabButton} @@ -351,12 +351,12 @@ export async function showModelModal(model, modelType) { ` ${versionsTabButton}`; - + const loadingExampleImagesText = translate('modals.model.loading.exampleImages', {}, 'Loading example images...'); const loadingDescriptionText = translate('modals.model.loading.description', {}, 'Loading model description...'); const loadingRecipesText = translate('modals.model.loading.recipes', {}, 'Loading recipes...'); const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...'); - + const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...'); const civitaiModelId = modelWithFullData.civitai?.modelId || ''; const civitaiVersionId = modelWithFullData.civitai?.id || ''; @@ -373,7 +373,7 @@ export async function showModelModal(model, modelType) {
`.trim(); - const tabPanesContent = modelType === 'loras' ? + const tabPanesContent = modelType === 'loras' ? `
${loadingExampleImagesText} @@ -518,7 +518,7 @@ export async function showModelModal(model, modelType) {
`; - + function updateVersionsTabBadge(hasUpdate) { const modalElement = document.getElementById(modalId); if (!modalElement) return; @@ -594,10 +594,10 @@ export async function showModelModal(model, modelType) { updateVersionsTabBadge(hasUpdate); updateCardUpdateAvailability(hasUpdate); } - + let showcaseCleanup; - const onCloseCallback = function() { + const onCloseCallback = function () { // Clean up all handlers when modal closes for LoRA const modalElement = document.getElementById(modalId); if (modalElement && modalElement._clickHandler) { @@ -610,7 +610,7 @@ export async function showModelModal(model, modelType) { } cleanupNavigationShortcuts(); }; - + modalManager.showModal(modalId, content, null, onCloseCallback); const activeModalElement = document.getElementById(modalId); if (activeModalElement) { @@ -643,17 +643,17 @@ export async function showModelModal(model, modelType) { setupEventHandlers(modelWithFullData.file_path, modelType); setupNavigationShortcuts(modelType); updateNavigationControls(); - + // LoRA specific setup if (modelType === 'loras' || modelType === 'embeddings') { setupTriggerWordsEditMode(); - + if (modelType == 'loras') { // Load recipes for this LoRA loadRecipesForLora(modelWithFullData.model_name, modelWithFullData.sha256); } } - + // Load example images asynchronously - merge regular and custom images const regularImages = modelWithFullData.civitai?.images || []; const customImages = modelWithFullData.civitai?.customImages || []; @@ -707,17 +707,17 @@ function detachModalHandlers(modalId) { */ function setupEventHandlers(filePath, modelType) { const modalElement = document.getElementById('modelModal'); - + // Remove existing event listeners first modalElement.removeEventListener('click', handleModalClick); - + // Create and store the handler function function handleModalClick(event) { const target = event.target.closest('[data-action]'); if (!target) return; - + const action = target.dataset.action; - + switch (action) { case 'close-modal': modalManager.closeModal('modelModal'); @@ -748,10 +748,10 @@ function setupEventHandlers(filePath, modelType) { break; } } - + // Add the event listener with the named function modalElement.addEventListener('click', handleModalClick); - + // Store reference to the handler on the element for potential cleanup modalElement._clickHandler = handleModalClick; } @@ -763,15 +763,15 @@ function setupEventHandlers(filePath, modelType) { */ function setupEditableFields(filePath, modelType) { const editableFields = document.querySelectorAll('.editable-field [contenteditable]'); - + editableFields.forEach(field => { - field.addEventListener('focus', function() { + field.addEventListener('focus', function () { if (this.textContent === 'Add your notes here...') { this.textContent = ''; } }); - field.addEventListener('blur', function() { + field.addEventListener('blur', function () { if (this.textContent.trim() === '') { if (this.classList.contains('notes-content')) { this.textContent = 'Add your notes here...'; @@ -783,7 +783,7 @@ function setupEditableFields(filePath, modelType) { // Add keydown event listeners for notes const notesContent = document.querySelector('.notes-content'); if (notesContent) { - notesContent.addEventListener('keydown', async function(e) { + notesContent.addEventListener('keydown', async function (e) { if (e.key === 'Enter') { if (e.shiftKey) { // Allow shift+enter for new line @@ -810,7 +810,7 @@ function setupLoraSpecificFields(filePath) { if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return; - presetSelector.addEventListener('change', function() { + presetSelector.addEventListener('change', function () { const selected = this.value; if (selected) { presetValue.style.display = 'inline-block'; @@ -828,10 +828,10 @@ function setupLoraSpecificFields(filePath) { } }); - addPresetBtn.addEventListener('click', async function() { + addPresetBtn.addEventListener('click', async function () { const key = presetSelector.value; const value = presetValue.value; - + if (!key || !value) return; const currentPath = resolveFilePath(); @@ -839,21 +839,21 @@ function setupLoraSpecificFields(filePath) { const loraCard = document.querySelector(`.model-card[data-filepath="${currentPath}"]`) || document.querySelector(`.model-card[data-filepath="${filePath}"]`); const currentPresets = parsePresets(loraCard?.dataset.usage_tips); - + currentPresets[key] = parseFloat(value); const newPresetsJson = JSON.stringify(currentPresets); await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson }); presetTags.innerHTML = renderPresetTags(currentPresets); - + presetSelector.value = ''; presetValue.value = ''; presetValue.style.display = 'none'; }); // Add keydown event for preset value - presetValue.addEventListener('keydown', function(e) { + presetValue.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); addPresetBtn.click(); diff --git a/static/js/components/shared/TriggerWords.js b/static/js/components/shared/TriggerWords.js index dfc1cb17..ea9d929a 100644 --- a/static/js/components/shared/TriggerWords.js +++ b/static/js/components/shared/TriggerWords.js @@ -6,7 +6,7 @@ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; import { translate } from '../../utils/i18nHelpers.js'; import { getModelApiClient } from '../../api/modelApiFactory.js'; -import { escapeAttribute } from './utils.js'; +import { escapeAttribute, escapeHtml } from './utils.js'; /** * Fetch trained words for a model @@ -17,7 +17,7 @@ async function fetchTrainedWords(filePath) { try { const response = await fetch(`/api/lm/trained-words?file_path=${encodeURIComponent(filePath)}`); const data = await response.json(); - + if (data.success) { return { trainedWords: data.trained_words || [], // Returns array of [word, frequency] pairs @@ -43,11 +43,11 @@ async function fetchTrainedWords(filePath) { function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) { const dropdown = document.createElement('div'); dropdown.className = 'metadata-suggestions-dropdown'; - + // Create header const header = document.createElement('div'); header.className = 'metadata-suggestions-header'; - + // No suggestions case if ((!trainedWords || trainedWords.length === 0) && !classTokens) { header.innerHTML = `${translate('modals.model.triggerWords.suggestions.noSuggestions')}`; @@ -55,12 +55,12 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) dropdown.innerHTML += `
${translate('modals.model.triggerWords.suggestions.noTrainedWords')}
`; return dropdown; } - + // Sort trained words by frequency (highest first) if available if (trainedWords && trainedWords.length > 0) { trainedWords.sort((a, b) => b[1] - a[1]); } - + // Add class tokens section if available if (classTokens) { // Add class tokens header @@ -71,45 +71,47 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) ${translate('modals.model.triggerWords.suggestions.classTokenDescription')} `; dropdown.appendChild(classTokensHeader); - + // Add class tokens container const classTokensContainer = document.createElement('div'); classTokensContainer.className = 'class-tokens-container'; - + // Create a special item for the class token const tokenItem = document.createElement('div'); tokenItem.className = `metadata-suggestion-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`; tokenItem.title = `${translate('modals.model.triggerWords.suggestions.classToken')}: ${classTokens}`; + + const escapedToken = escapeHtml(classTokens); tokenItem.innerHTML = ` - ${classTokens} + ${escapedToken}
${translate('modals.model.triggerWords.suggestions.classToken')} - ${existingWords.includes(classTokens) ? - `` : ''} + ${existingWords.includes(classTokens) ? + `` : ''}
`; - + // Add click handler if not already added if (!existingWords.includes(classTokens)) { tokenItem.addEventListener('click', () => { // Automatically add this word addNewTriggerWord(classTokens); - + // Also populate the input field for potential editing const input = document.querySelector('.metadata-input'); if (input) input.value = classTokens; - + // Focus on the input if (input) input.focus(); - + // Update dropdown without removing it updateTrainedWordsDropdown(); }); } - + classTokensContainer.appendChild(tokenItem); dropdown.appendChild(classTokensContainer); - + // Add separator if we also have trained words if (trainedWords && trainedWords.length > 0) { const separator = document.createElement('div'); @@ -117,7 +119,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) dropdown.appendChild(separator); } } - + // Add trained words header if we have any if (trainedWords && trainedWords.length > 0) { header.innerHTML = ` @@ -125,52 +127,54 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) ${translate('modals.model.triggerWords.suggestions.wordsFound', { count: trainedWords.length })} `; dropdown.appendChild(header); - + // Create tag container for trained words const container = document.createElement('div'); container.className = 'metadata-suggestions-container'; - + // Add each trained word as a tag trainedWords.forEach(([word, frequency]) => { const isAdded = existingWords.includes(word); - + const item = document.createElement('div'); item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`; item.title = word; // Show full word on hover if truncated + + const escapedWord = escapeHtml(word); item.innerHTML = ` - ${word} + ${escapedWord}
${frequency} ${isAdded ? `` : ''}
`; - + if (!isAdded) { item.addEventListener('click', () => { // Automatically add this word addNewTriggerWord(word); - + // Also populate the input field for potential editing const input = document.querySelector('.metadata-input'); if (input) input.value = word; - + // Focus on the input if (input) input.focus(); - + // Update dropdown without removing it updateTrainedWordsDropdown(); }); } - + container.appendChild(item); }); - + dropdown.appendChild(container); } else if (!classTokens) { // If we have neither class tokens nor trained words dropdown.innerHTML += `
${translate('modals.model.triggerWords.suggestions.noTrainedWords')}
`; } - + return dropdown; } @@ -204,7 +208,7 @@ export function renderTriggerWords(words, filePath) { `; - + return `
@@ -215,9 +219,12 @@ export function renderTriggerWords(words, filePath) {
- ${words.map(word => ` -
- ${word} + ${words.map(word => { + const escapedWord = escapeHtml(word); + const escapedAttr = escapeAttribute(word); + return ` +
+ ${escapedWord} @@ -225,7 +232,7 @@ export function renderTriggerWords(words, filePath) {
- `).join('')} + `}).join('')}