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
This commit is contained in:
Will Miao
2025-12-11 18:07:56 +08:00
parent fbb95bc623
commit 675d49e4ce
3 changed files with 31 additions and 18 deletions

View File

@@ -14,7 +14,7 @@ import {
} from './ModelMetadata.js'; } from './ModelMetadata.js';
import { setupTagEditMode } from './ModelTags.js'; import { setupTagEditMode } from './ModelTags.js';
import { getModelApiClient } from '../../api/modelApiFactory.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 { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
import { parsePresets, renderPresetTags } from './PresetTags.js'; import { parsePresets, renderPresetTags } from './PresetTags.js';
import { initVersionsTab } from './ModelVersionsTab.js'; import { initVersionsTab } from './ModelVersionsTab.js';
@@ -65,12 +65,6 @@ function hasLicenseField(license, field) {
return Object.prototype.hasOwnProperty.call(license || {}, field); return Object.prototype.hasOwnProperty.call(license || {}, field);
} }
function escapeAttribute(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/"/g, '"');
}
function indentMarkup(markup, spaces) { function indentMarkup(markup, spaces) {
if (!markup) { if (!markup) {
return ''; return '';
@@ -266,9 +260,11 @@ export async function showModelModal(model, modelType) {
...model, ...model,
civitai: completeCivitaiData civitai: completeCivitaiData
}; };
const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || '');
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A');
const licenseIcons = renderLicenseIcons(modelWithFullData); const licenseIcons = renderLicenseIcons(modelWithFullData);
const viewOnCivitaiAction = modelWithFullData.from_civitai ? ` const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${modelWithFullData.file_path}"> <div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')} <i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
</div>`.trim() : ''; </div>`.trim() : '';
const creatorInfoAction = modelWithFullData.civitai?.creator ? ` const creatorInfoAction = modelWithFullData.civitai?.creator ? `
@@ -472,8 +468,8 @@ export async function showModelModal(model, modelType) {
<label>${translate('modals.model.metadata.location', {}, 'Location')}</label> <label>${translate('modals.model.metadata.location', {}, 'Location')}</label>
<span class="file-path" title="${translate('modals.model.actions.openFileLocation', {}, 'Open file location')}" <span class="file-path" title="${translate('modals.model.actions.openFileLocation', {}, 'Open file location')}"
data-action="open-file-location" data-action="open-file-location"
data-filepath="${modelWithFullData.file_path}"> data-filepath="${escapedFilePathAttr}">
${modelWithFullData.file_path.replace(/[^/]+$/, '') || 'N/A'} ${escapedFolderPath}
</span> </span>
</div> </div>
</div> </div>
@@ -506,7 +502,7 @@ export async function showModelModal(model, modelType) {
</div> </div>
</div> </div>
<div class="showcase-section" data-model-hash="${modelWithFullData.sha256 || ''}" data-filepath="${modelWithFullData.file_path}"> <div class="showcase-section" data-model-hash="${modelWithFullData.sha256 || ''}" data-filepath="${escapedFilePathAttr}">
<div class="showcase-tabs"> <div class="showcase-tabs">
${tabsContent} ${tabsContent}
</div> </div>

View File

@@ -6,6 +6,7 @@
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js'; import { translate } from '../../utils/i18nHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js'; import { getModelApiClient } from '../../api/modelApiFactory.js';
import { escapeAttribute } from './utils.js';
/** /**
* Fetch trained words for a model * Fetch trained words for a model
@@ -180,11 +181,12 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
* @returns {string} HTML content * @returns {string} HTML content
*/ */
export function renderTriggerWords(words, filePath) { export function renderTriggerWords(words, filePath) {
const safeFilePath = escapeAttribute(filePath || '');
if (!words.length) return ` if (!words.length) return `
<div class="info-item full-width trigger-words"> <div class="info-item full-width trigger-words">
<div class="trigger-words-header"> <div class="trigger-words-header">
<label>${translate('modals.model.triggerWords.label')}</label> <label>${translate('modals.model.triggerWords.label')}</label>
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="${translate('modals.model.triggerWords.edit')}"> <button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${safeFilePath}" title="${translate('modals.model.triggerWords.edit')}">
<i class="fas fa-pencil-alt"></i> <i class="fas fa-pencil-alt"></i>
</button> </button>
</div> </div>
@@ -207,7 +209,7 @@ export function renderTriggerWords(words, filePath) {
<div class="info-item full-width trigger-words"> <div class="info-item full-width trigger-words">
<div class="trigger-words-header"> <div class="trigger-words-header">
<label>${translate('modals.model.triggerWords.label')}</label> <label>${translate('modals.model.triggerWords.label')}</label>
<button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${filePath}" title="${translate('modals.model.triggerWords.edit')}"> <button class="edit-trigger-words-btn metadata-edit-btn" data-file-path="${safeFilePath}" title="${translate('modals.model.triggerWords.edit')}">
<i class="fas fa-pencil-alt"></i> <i class="fas fa-pencil-alt"></i>
</button> </button>
</div> </div>

View File

@@ -3,6 +3,20 @@
* Helper functions for the Model Modal component - General version * Helper functions for the Model Modal component - General version
*/ */
export function escapeHtml(value = '') {
if (value === null || value === undefined) return '';
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function escapeAttribute(value = '') {
return escapeHtml(value);
}
/** /**
* Format file size * Format file size
* @param {number} bytes - Number of bytes * @param {number} bytes - Number of bytes
@@ -31,6 +45,7 @@ export function formatFileSize(bytes) {
export function renderCompactTags(tags, filePath = '') { export function renderCompactTags(tags, filePath = '') {
// Remove the early return and always render the container // Remove the early return and always render the container
const tagsList = tags || []; const tagsList = tags || [];
const safeFilePath = escapeAttribute(filePath || '');
// Display up to 5 tags, with a tooltip indicator if there are more // Display up to 5 tags, with a tooltip indicator if there are more
const visibleTags = tagsList.slice(0, 5); const visibleTags = tagsList.slice(0, 5);
@@ -40,20 +55,20 @@ export function renderCompactTags(tags, filePath = '') {
<div class="model-tags-container"> <div class="model-tags-container">
<div class="model-tags-header"> <div class="model-tags-header">
<div class="model-tags-compact"> <div class="model-tags-compact">
${visibleTags.map(tag => `<span class="model-tag-compact">${tag}</span>`).join('')} ${visibleTags.map(tag => `<span class="model-tag-compact">${escapeHtml(tag)}</span>`).join('')}
${remainingCount > 0 ? ${remainingCount > 0 ?
`<span class="model-tag-more" data-count="${remainingCount}">+${remainingCount}</span>` : `<span class="model-tag-more" data-count="${remainingCount}">+${remainingCount}</span>` :
''} ''}
${tagsList.length === 0 ? `<span class="model-tag-empty">No tags</span>` : ''} ${tagsList.length === 0 ? `<span class="model-tag-empty">No tags</span>` : ''}
</div> </div>
<button class="edit-tags-btn" data-file-path="${filePath}" title="Edit tags"> <button class="edit-tags-btn" data-file-path="${safeFilePath}" title="Edit tags">
<i class="fas fa-pencil-alt"></i> <i class="fas fa-pencil-alt"></i>
</button> </button>
</div> </div>
${tagsList.length > 0 ? ${tagsList.length > 0 ?
`<div class="model-tags-tooltip"> `<div class="model-tags-tooltip">
<div class="tooltip-content"> <div class="tooltip-content">
${tagsList.map(tag => `<span class="tooltip-tag">${tag}</span>`).join('')} ${tagsList.map(tag => `<span class="tooltip-tag">${escapeHtml(tag)}</span>`).join('')}
</div> </div>
</div>` : </div>` :
''} ''}