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

@@ -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;

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,8 +80,10 @@ 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) ?
@@ -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;">
@@ -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>

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)"');
});
});