From 675d49e4ce387dc15d5cc076eec558af96c6d9aa Mon Sep 17 00:00:00 2001 From: Will Miao Date: Thu, 11 Dec 2025 18:07:56 +0800 Subject: [PATCH] feat(security): escape HTML attributes and content in model modal, fixes #720 - Import `escapeAttribute` and `escapeHtml` utilities from shared utils - Remove duplicate `escapeAttribute` function from ModelModal.js - Apply escaping to file path attributes in model modal and trigger words - Escape folder path HTML content to prevent XSS vulnerabilities - Ensure safe handling of user-controlled data in UI components --- static/js/components/shared/ModelModal.js | 18 +++++++--------- static/js/components/shared/TriggerWords.js | 8 ++++--- static/js/components/shared/utils.js | 23 +++++++++++++++++---- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index 5c7493ed..84e1dbb3 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -14,7 +14,7 @@ import { } from './ModelMetadata.js'; import { setupTagEditMode } from './ModelTags.js'; import { getModelApiClient } from '../../api/modelApiFactory.js'; -import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js'; +import { renderCompactTags, setupTagTooltip, formatFileSize, escapeAttribute, escapeHtml } from './utils.js'; import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js'; import { parsePresets, renderPresetTags } from './PresetTags.js'; import { initVersionsTab } from './ModelVersionsTab.js'; @@ -65,12 +65,6 @@ function hasLicenseField(license, field) { return Object.prototype.hasOwnProperty.call(license || {}, field); } -function escapeAttribute(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(/"/g, '"'); -} - function indentMarkup(markup, spaces) { if (!markup) { return ''; @@ -266,9 +260,11 @@ export async function showModelModal(model, modelType) { ...model, civitai: completeCivitaiData }; + const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || ''); + const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A'); const licenseIcons = renderLicenseIcons(modelWithFullData); const viewOnCivitaiAction = modelWithFullData.from_civitai ? ` -
+
${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
`.trim() : ''; const creatorInfoAction = modelWithFullData.civitai?.creator ? ` @@ -472,8 +468,8 @@ export async function showModelModal(model, modelType) { - ${modelWithFullData.file_path.replace(/[^/]+$/, '') || 'N/A'} + data-filepath="${escapedFilePathAttr}"> + ${escapedFolderPath}
@@ -506,7 +502,7 @@ export async function showModelModal(model, modelType) { -
+
${tabsContent}
diff --git a/static/js/components/shared/TriggerWords.js b/static/js/components/shared/TriggerWords.js index dd82b3d6..dfc1cb17 100644 --- a/static/js/components/shared/TriggerWords.js +++ b/static/js/components/shared/TriggerWords.js @@ -6,6 +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'; /** * Fetch trained words for a model @@ -180,11 +181,12 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) * @returns {string} HTML content */ export function renderTriggerWords(words, filePath) { + const safeFilePath = escapeAttribute(filePath || ''); if (!words.length) return `
-
@@ -207,7 +209,7 @@ export function renderTriggerWords(words, filePath) {
-
@@ -647,4 +649,4 @@ window.copyTriggerWord = async function(word) { console.error('Copy failed:', err); showToast('toast.triggerWords.copyFailed', {}, 'error'); } -}; \ No newline at end of file +}; diff --git a/static/js/components/shared/utils.js b/static/js/components/shared/utils.js index 0e5a5c1f..a0226e05 100644 --- a/static/js/components/shared/utils.js +++ b/static/js/components/shared/utils.js @@ -3,6 +3,20 @@ * Helper functions for the Model Modal component - General version */ +export function escapeHtml(value = '') { + if (value === null || value === undefined) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function escapeAttribute(value = '') { + return escapeHtml(value); +} + /** * Format file size * @param {number} bytes - Number of bytes @@ -31,6 +45,7 @@ export function formatFileSize(bytes) { export function renderCompactTags(tags, filePath = '') { // Remove the early return and always render the container const tagsList = tags || []; + const safeFilePath = escapeAttribute(filePath || ''); // Display up to 5 tags, with a tooltip indicator if there are more const visibleTags = tagsList.slice(0, 5); @@ -40,20 +55,20 @@ export function renderCompactTags(tags, filePath = '') {
- ${visibleTags.map(tag => `${tag}`).join('')} + ${visibleTags.map(tag => `${escapeHtml(tag)}`).join('')} ${remainingCount > 0 ? `+${remainingCount}` : ''} ${tagsList.length === 0 ? `No tags` : ''}
-
${tagsList.length > 0 ? `
- ${tagsList.map(tag => `${tag}`).join('')} + ${tagsList.map(tag => `${escapeHtml(tag)}`).join('')}
` : ''} @@ -77,4 +92,4 @@ export function setupTagTooltip() { tooltip.classList.remove('visible'); }); } -} \ No newline at end of file +}