mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 15:15:44 -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:
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
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