mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 23:25:43 -03:00
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:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
55
tests/frontend/components/triggerWords.escaping.test.js
Normal file
55
tests/frontend/components/triggerWords.escaping.test.js
Normal 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("<style>guangying</style>");
|
||||||
|
expect(html).toContain("fym <artist>");
|
||||||
|
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'with'quotes"');
|
||||||
|
expect(html).toContain('data-word="<tag>"');
|
||||||
|
|
||||||
|
// Check for the onclick handler
|
||||||
|
expect(html).toContain('onclick="copyTriggerWord(this.dataset.word)"');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user