feat: Add HTML and attribute escaping for trigger words and class tokens to prevent XSS vulnerabilities, along with new frontend tests. Fixes #732

This commit is contained in:
Will Miao
2025-12-23 08:47:15 +08:00
parent 3ba5c4c2ab
commit dd89aa49c1
3 changed files with 208 additions and 142 deletions

View File

@@ -270,13 +270,13 @@ export async function showModelModal(model, modelType) {
const creatorInfoAction = modelWithFullData.civitai?.creator ? ` const creatorInfoAction = modelWithFullData.civitai?.creator ? `
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}"> <div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
${modelWithFullData.civitai.creator.image ? ${modelWithFullData.civitai.creator.image ?
`<div class="creator-avatar"> `<div class="creator-avatar">
<img src="${modelWithFullData.civitai.creator.image}" alt="${modelWithFullData.civitai.creator.username}" onerror="this.onerror=null; this.src='/loras_static/icons/user-placeholder.png';"> <img src="${modelWithFullData.civitai.creator.image}" alt="${modelWithFullData.civitai.creator.username}" onerror="this.onerror=null; this.src='/loras_static/icons/user-placeholder.png';">
</div>` : </div>` :
`<div class="creator-avatar creator-placeholder"> `<div class="creator-avatar creator-placeholder">
<i class="fas fa-user"></i> <i class="fas fa-user"></i>
</div>` </div>`
} }
<span class="creator-username">${modelWithFullData.civitai.creator.username}</span> <span class="creator-username">${modelWithFullData.civitai.creator.username}</span>
</div>`.trim() : ''; </div>`.trim() : '';
const creatorActionItems = []; const creatorActionItems = [];
@@ -313,7 +313,7 @@ export async function showModelModal(model, modelType) {
// Prepare LoRA specific data with complete civitai data // Prepare LoRA specific data with complete civitai data
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ? const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
modelWithFullData.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : []; modelWithFullData.civitai.trainedWords : [];
// Generate model type specific content // Generate model type specific content
let typeSpecificContent; let typeSpecificContent;
@@ -597,7 +597,7 @@ export async function showModelModal(model, modelType) {
let showcaseCleanup; let showcaseCleanup;
const onCloseCallback = function() { const onCloseCallback = function () {
// Clean up all handlers when modal closes for LoRA // Clean up all handlers when modal closes for LoRA
const modalElement = document.getElementById(modalId); const modalElement = document.getElementById(modalId);
if (modalElement && modalElement._clickHandler) { if (modalElement && modalElement._clickHandler) {
@@ -765,13 +765,13 @@ function setupEditableFields(filePath, modelType) {
const editableFields = document.querySelectorAll('.editable-field [contenteditable]'); const editableFields = document.querySelectorAll('.editable-field [contenteditable]');
editableFields.forEach(field => { editableFields.forEach(field => {
field.addEventListener('focus', function() { field.addEventListener('focus', function () {
if (this.textContent === 'Add your notes here...') { if (this.textContent === 'Add your notes here...') {
this.textContent = ''; this.textContent = '';
} }
}); });
field.addEventListener('blur', function() { field.addEventListener('blur', function () {
if (this.textContent.trim() === '') { if (this.textContent.trim() === '') {
if (this.classList.contains('notes-content')) { if (this.classList.contains('notes-content')) {
this.textContent = 'Add your notes here...'; this.textContent = 'Add your notes here...';
@@ -783,7 +783,7 @@ function setupEditableFields(filePath, modelType) {
// Add keydown event listeners for notes // Add keydown event listeners for notes
const notesContent = document.querySelector('.notes-content'); const notesContent = document.querySelector('.notes-content');
if (notesContent) { if (notesContent) {
notesContent.addEventListener('keydown', async function(e) { notesContent.addEventListener('keydown', async function (e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
if (e.shiftKey) { if (e.shiftKey) {
// Allow shift+enter for new line // Allow shift+enter for new line
@@ -810,7 +810,7 @@ function setupLoraSpecificFields(filePath) {
if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return; if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return;
presetSelector.addEventListener('change', function() { presetSelector.addEventListener('change', function () {
const selected = this.value; const selected = this.value;
if (selected) { if (selected) {
presetValue.style.display = 'inline-block'; presetValue.style.display = 'inline-block';
@@ -828,7 +828,7 @@ function setupLoraSpecificFields(filePath) {
} }
}); });
addPresetBtn.addEventListener('click', async function() { addPresetBtn.addEventListener('click', async function () {
const key = presetSelector.value; const key = presetSelector.value;
const value = presetValue.value; const value = presetValue.value;
@@ -853,7 +853,7 @@ function setupLoraSpecificFields(filePath) {
}); });
// Add keydown event for preset value // Add keydown event for preset value
presetValue.addEventListener('keydown', function(e) { presetValue.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
addPresetBtn.click(); addPresetBtn.click();

View File

@@ -6,7 +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'; import { escapeAttribute, escapeHtml } from './utils.js';
/** /**
* Fetch trained words for a model * Fetch trained words for a model
@@ -80,12 +80,14 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
const tokenItem = document.createElement('div'); const tokenItem = document.createElement('div');
tokenItem.className = `metadata-suggestion-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`; tokenItem.className = `metadata-suggestion-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`;
tokenItem.title = `${translate('modals.model.triggerWords.suggestions.classToken')}: ${classTokens}`; tokenItem.title = `${translate('modals.model.triggerWords.suggestions.classToken')}: ${classTokens}`;
const escapedToken = escapeHtml(classTokens);
tokenItem.innerHTML = ` tokenItem.innerHTML = `
<span class="metadata-suggestion-text">${classTokens}</span> <span class="metadata-suggestion-text">${escapedToken}</span>
<div class="metadata-suggestion-meta"> <div class="metadata-suggestion-meta">
<span class="token-badge">${translate('modals.model.triggerWords.suggestions.classToken')}</span> <span class="token-badge">${translate('modals.model.triggerWords.suggestions.classToken')}</span>
${existingWords.includes(classTokens) ? ${existingWords.includes(classTokens) ?
`<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''} `<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
</div> </div>
`; `;
@@ -137,8 +139,10 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
const item = document.createElement('div'); const item = document.createElement('div');
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`; item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
item.title = word; // Show full word on hover if truncated item.title = word; // Show full word on hover if truncated
const escapedWord = escapeHtml(word);
item.innerHTML = ` item.innerHTML = `
<span class="metadata-suggestion-text">${word}</span> <span class="metadata-suggestion-text">${escapedWord}</span>
<div class="metadata-suggestion-meta"> <div class="metadata-suggestion-meta">
<span class="trained-word-freq">${frequency}</span> <span class="trained-word-freq">${frequency}</span>
${isAdded ? `<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''} ${isAdded ? `<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
@@ -215,9 +219,12 @@ export function renderTriggerWords(words, filePath) {
</div> </div>
<div class="trigger-words-content"> <div class="trigger-words-content">
<div class="trigger-words-tags"> <div class="trigger-words-tags">
${words.map(word => ` ${words.map(word => {
<div class="trigger-word-tag" data-word="${word}" onclick="copyTriggerWord('${word}')" title="${translate('modals.model.triggerWords.copyWord')}"> const escapedWord = escapeHtml(word);
<span class="trigger-word-content">${word}</span> const escapedAttr = escapeAttribute(word);
return `
<div class="trigger-word-tag" data-word="${escapedAttr}" onclick="copyTriggerWord(this.dataset.word)" title="${translate('modals.model.triggerWords.copyWord')}">
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy"> <span class="trigger-word-copy">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
</span> </span>
@@ -225,7 +232,7 @@ export function renderTriggerWords(words, filePath) {
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
`).join('')} `}).join('')}
</div> </div>
</div> </div>
<div class="metadata-edit-controls" style="display:none;"> <div class="metadata-edit-controls" style="display:none;">
@@ -254,7 +261,7 @@ export function setupTriggerWordsEditMode() {
const editBtn = document.querySelector('.edit-trigger-words-btn'); const editBtn = document.querySelector('.edit-trigger-words-btn');
if (!editBtn) return; if (!editBtn) return;
editBtn.addEventListener('click', async function() { editBtn.addEventListener('click', async function () {
const triggerWordsSection = this.closest('.trigger-words'); const triggerWordsSection = this.closest('.trigger-words');
const isEditMode = triggerWordsSection.classList.toggle('edit-mode'); const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath; const filePath = this.dataset.filePath;
@@ -368,7 +375,7 @@ export function setupTriggerWordsEditMode() {
if (triggerWordInput) { if (triggerWordInput) {
// Add keydown event to input // Add keydown event to input
triggerWordInput.addEventListener('keydown', function(e) { triggerWordInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
addNewTriggerWord(this.value); addNewTriggerWord(this.value);
@@ -417,7 +424,7 @@ function resetTriggerWordsUIState(section) {
const deleteBtn = tag.querySelector('.metadata-delete-btn'); const deleteBtn = tag.querySelector('.metadata-delete-btn');
// Restore click-to-copy functionality // Restore click-to-copy functionality
tag.onclick = () => copyTriggerWord(word); tag.onclick = () => copyTriggerWord(tag.dataset.word);
// Show copy icon, hide delete button // Show copy icon, hide delete button
if (copyIcon) copyIcon.style.display = ''; if (copyIcon) copyIcon.style.display = '';
@@ -454,9 +461,11 @@ function restoreOriginalTriggerWords(section, originalWords) {
const tag = document.createElement('div'); const tag = document.createElement('div');
tag.className = 'trigger-word-tag'; tag.className = 'trigger-word-tag';
tag.dataset.word = word; tag.dataset.word = word;
tag.onclick = () => copyTriggerWord(word); tag.onclick = () => copyTriggerWord(tag.dataset.word);
const escapedWord = escapeHtml(word);
tag.innerHTML = ` tag.innerHTML = `
<span class="trigger-word-content">${word}</span> <span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy"> <span class="trigger-word-copy">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
</span> </span>
@@ -524,8 +533,10 @@ function addNewTriggerWord(word) {
const newTag = document.createElement('div'); const newTag = document.createElement('div');
newTag.className = 'trigger-word-tag'; newTag.className = 'trigger-word-tag';
newTag.dataset.word = word; newTag.dataset.word = word;
const escapedWord = escapeHtml(word);
newTag.innerHTML = ` newTag.innerHTML = `
<span class="trigger-word-content">${word}</span> <span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy" style="display:none;"> <span class="trigger-word-copy" style="display:none;">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
</span> </span>
@@ -642,7 +653,7 @@ async function saveTriggerWords() {
* Copy a trigger word to clipboard * Copy a trigger word to clipboard
* @param {string} word - Word to copy * @param {string} word - Word to copy
*/ */
window.copyTriggerWord = async function(word) { window.copyTriggerWord = async function (word) {
try { try {
await copyToClipboard(word, 'Trigger word copied'); await copyToClipboard(word, 'Trigger word copied');
} catch (err) { } catch (err) {

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
const {
TRIGGER_WORDS_MODULE,
UTILS_MODULE,
I18N_HELPERS_MODULE,
} = vi.hoisted(() => ({
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((key, params, fallback) => fallback || key),
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
copyToClipboard: vi.fn(),
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: vi.fn(),
}));
describe("TriggerWords HTML Escaping", () => {
let renderTriggerWords;
beforeEach(async () => {
document.body.innerHTML = '';
const module = await import(TRIGGER_WORDS_MODULE);
renderTriggerWords = module.renderTriggerWords;
});
it("escapes HTML tags in trigger words rendering", () => {
const words = ["<style>guangying</style>", "fym <artist>"];
const html = renderTriggerWords(words, "test.safetensors");
expect(html).toContain("&lt;style&gt;guangying&lt;/style&gt;");
expect(html).toContain("fym &lt;artist&gt;");
expect(html).not.toContain("<style>guangying</style>");
});
it("uses dataset for copyTriggerWord to safely handle special characters", () => {
const words = ["word'with'quotes", "<tag>"];
const html = renderTriggerWords(words, "test.safetensors");
// Check for dataset-word attribute
expect(html).toContain('data-word="word&#39;with&#39;quotes"');
expect(html).toContain('data-word="&lt;tag&gt;"');
// Check for the onclick handler
expect(html).toContain('onclick="copyTriggerWord(this.dataset.word)"');
});
});