fix(web): escape server data in innerHTML and plug listener leaks

- Escape file names, paths and image metadata interpolated into
  innerHTML in the local image loader and save image viewer (XSS via
  crafted file names or a shared workflow's filename_prefix)
- Save image viewer: bind document drag listeners once and remove them
  in disconnectedCallback instead of adding two per run; drop debug
  logging and the window.kikoSaveImageTest helper
- Local image loader: check response.ok and surface 400/403 errors,
  disconnect the ResizeObserver on node removal
- Gemini prompt: restore the user's node color after the run flash
- Workflow timer: chain onRemoved, merge properties in onConfigure
- Seed history: build entries with textContent
This commit is contained in:
Vito Sansevero
2026-10-13 06:32:14 -07:00
parent c7f24a3262
commit 37c00b633c
5 changed files with 175 additions and 112 deletions
+25 -4
View File
@@ -138,8 +138,16 @@ app.registerExtension({
const result = onExecuted?.apply(this, arguments);
if (this.status) {
// Remember the user's color only if no flash is already pending,
// otherwise we would capture our own temporary flash color
if (this.geminiFlashTimeout) {
clearTimeout(this.geminiFlashTimeout);
} else {
this.geminiPrevBgcolor = this.bgcolor;
}
// Check if there was an error in the output
const outputs = message.output;
const outputs = message?.output;
if (outputs && outputs.prompt && outputs.prompt[0] && outputs.prompt[0].startsWith("Error:")) {
this.status.value = "Error - Check output";
this.bgcolor = "#552222";
@@ -148,17 +156,30 @@ app.registerExtension({
this.bgcolor = "#225522";
}
// Reset color after delay
setTimeout(() => {
this.bgcolor = "";
// Restore the previous color after delay
this.geminiFlashTimeout = setTimeout(() => {
this.geminiFlashTimeout = null;
this.bgcolor = this.geminiPrevBgcolor;
this.geminiPrevBgcolor = undefined;
if (this.status) {
this.status.value = "Ready";
}
this.setDirtyCanvas?.(true, true);
}, 3000);
}
return result;
};
// Clear any pending color-restore timer when the node is removed
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function() {
if (this.geminiFlashTimeout) {
clearTimeout(this.geminiFlashTimeout);
this.geminiFlashTimeout = null;
}
return onRemoved?.apply(this, arguments);
};
}
},
+89 -13
View File
@@ -1,6 +1,34 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
// Escape a value for safe interpolation into HTML text or attribute contexts
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Build a readable error message from a non-OK fetch response
async function describeHttpError(response) {
let detail = '';
try {
const body = await response.json();
detail = body?.error || body?.message || '';
} catch (e) {
// Non-JSON body; fall back to the status code
}
if (response.status === 403) {
return detail || 'Access denied: path is outside the allowed folders';
}
if (response.status === 400) {
return detail || 'Invalid path or request';
}
return detail || `Request failed (HTTP ${response.status})`;
}
// Setup global lightbox for image preview
function setupGlobalLightbox() {
if (document.getElementById('kiko-image-lightbox')) return;
@@ -511,7 +539,22 @@ app.registerExtension({
const debouncedLayout = debounce(applyMasonryLayout, 20);
// Set up ResizeObserver for responsive layout
new ResizeObserver(debouncedLayout).observe(cardHolder);
this.kikoResizeObserver = new ResizeObserver(debouncedLayout);
this.kikoResizeObserver.observe(cardHolder);
const originalOnRemoved = this.onRemoved;
this.onRemoved = function () {
if (this.kikoResizeObserver) {
this.kikoResizeObserver.disconnect();
this.kikoResizeObserver = null;
}
return originalOnRemoved?.apply(this, arguments);
};
const showStatus = (message) => {
statusMessage.style.display = 'block';
statusMessage.textContent = message;
};
// Autocomplete functionality
let autocompleteSelectedIndex = -1;
@@ -521,6 +564,10 @@ app.registerExtension({
async function fetchDirectorySuggestions(path) {
try {
const response = await api.fetchApi(`/kiko_local_image_loader/list_directories?path=${encodeURIComponent(path)}`);
if (!response.ok) {
// Disallowed or invalid paths simply yield no suggestions
return [];
}
const data = await response.json();
return data.directories || [];
} catch (error) {
@@ -545,11 +592,11 @@ app.registerExtension({
const parentPath = parts.length > 0 ? '/' + parts.join('/') : '';
return `
<div class="kiko-autocomplete-item" data-index="${index}" data-path="${path}">
<div class="kiko-autocomplete-item" data-index="${index}" data-path="${escapeHtml(path)}">
<div class="folder-icon">${smallFolderSVG}</div>
<div class="path-text">
<span class="folder-name">${folderName}</span>
${parentPath ? `<span class="parent-path"> — ${parentPath}</span>` : ''}
<span class="folder-name">${escapeHtml(folderName)}</span>
${parentPath ? `<span class="parent-path"> — ${escapeHtml(parentPath)}</span>` : ''}
</div>
</div>
`;
@@ -668,6 +715,9 @@ app.registerExtension({
async function loadSavedPaths() {
try {
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
if (!response.ok) {
throw new Error(await describeHttpError(response));
}
const data = await response.json();
pathPresets.innerHTML = '<option value="">-- Saved Paths --</option>';
@@ -689,20 +739,27 @@ app.registerExtension({
try {
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
if (!response.ok) {
throw new Error(await describeHttpError(response));
}
const data = await response.json();
const savedPaths = data.saved_paths || [];
if (!savedPaths.includes(currentDirectory)) {
savedPaths.push(currentDirectory);
await api.fetchApi('/kiko_local_image_loader/save_paths', {
const saveResponse = await api.fetchApi('/kiko_local_image_loader/save_paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: savedPaths })
});
if (!saveResponse.ok) {
throw new Error(await describeHttpError(saveResponse));
}
await loadSavedPaths();
}
} catch (error) {
console.error('Error saving path:', error);
showStatus(`Error saving path: ${error.message}`);
}
};
@@ -713,20 +770,27 @@ app.registerExtension({
try {
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
if (!response.ok) {
throw new Error(await describeHttpError(response));
}
const data = await response.json();
const savedPaths = data.saved_paths || [];
const updatedPaths = savedPaths.filter(p => p !== selectedPath);
if (updatedPaths.length !== savedPaths.length) {
await api.fetchApi('/kiko_local_image_loader/save_paths', {
const saveResponse = await api.fetchApi('/kiko_local_image_loader/save_paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: updatedPaths })
});
if (!saveResponse.ok) {
throw new Error(await describeHttpError(saveResponse));
}
await loadSavedPaths();
}
} catch (error) {
console.error('Error deleting path:', error);
showStatus(`Error deleting path: ${error.message}`);
}
};
@@ -757,6 +821,10 @@ app.registerExtension({
try {
const response = await api.fetchApi(`/kiko_local_image_loader/images?${params}`);
if (!response.ok) {
statusMessage.textContent = await describeHttpError(response);
return;
}
const data = await response.json();
if (data.error) {
@@ -820,7 +888,7 @@ app.registerExtension({
<div class="kiko-card-media-wrapper">
<div class="kiko-folder-card">
<div class="kiko-folder-icon">${folderSVG}</div>
<div class="kiko-folder-name">${item.name}</div>
<div class="kiko-folder-name">${escapeHtml(item.name)}</div>
</div>
</div>
`;
@@ -831,9 +899,9 @@ app.registerExtension({
const thumbnailUrl = `/kiko_local_image_loader/thumbnail?filepath=${encodeURIComponent(item.path)}`;
card.innerHTML = `
<div class="kiko-card-media-wrapper">
<img src="${thumbnailUrl}" alt="${item.name}" />
<img src="${escapeHtml(thumbnailUrl)}" alt="${escapeHtml(item.name)}" />
</div>
<div class="kiko-card-info-panel">${item.name}</div>
<div class="kiko-card-info-panel">${escapeHtml(item.name)}</div>
`;
// Trigger layout when image loads
@@ -856,10 +924,10 @@ app.registerExtension({
const thumbnailUrl = `/kiko_local_image_loader/thumbnail?filepath=${encodeURIComponent(item.path)}`;
card.innerHTML = `
<div class="kiko-card-media-wrapper">
<img src="${thumbnailUrl}" alt="${item.name}" />
<img src="${escapeHtml(thumbnailUrl)}" alt="${escapeHtml(item.name)}" />
<div class="kiko-video-card-overlay">${videoSVG}</div>
</div>
<div class="kiko-card-info-panel">${item.name}</div>
<div class="kiko-card-info-panel">${escapeHtml(item.name)}</div>
`;
const img = card.querySelector('img');
@@ -882,7 +950,7 @@ app.registerExtension({
<div class="kiko-card-media-wrapper">
<div class="kiko-audio-card">
<div class="kiko-audio-icon">${audioSVG}</div>
<div class="kiko-audio-name">${item.name}</div>
<div class="kiko-audio-name">${escapeHtml(item.name)}</div>
</div>
</div>
`;
@@ -924,7 +992,7 @@ app.registerExtension({
// Send to backend
try {
await api.fetchApi('/kiko_local_image_loader/set_node_selection', {
const response = await api.fetchApi('/kiko_local_image_loader/set_node_selection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -933,11 +1001,16 @@ app.registerExtension({
type: type
})
});
if (!response.ok) {
showStatus(`Selection rejected: ${await describeHttpError(response)}`);
return;
}
// Trigger node update
node.setDirtyCanvas(true);
} catch (error) {
console.error('Error setting selection:', error);
showStatus('Error setting selection');
}
}
@@ -997,10 +1070,13 @@ app.registerExtension({
// Try to load last path
api.fetchApi('/kiko_local_image_loader/get_last_path').then(async response => {
if (!response.ok) return;
const data = await response.json();
if (data.last_path) {
loadImages(data.last_path);
}
}).catch(error => {
console.error('Error loading last path:', error);
});
return r;
+49 -86
View File
@@ -377,6 +377,26 @@ function injectStyles() {
}
}
// Escape a value for safe interpolation into HTML text or attribute contexts
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Build a ComfyUI /api/view URL with every parameter encoded
function buildViewUrl(filename, subfolder, type) {
const params = new URLSearchParams({ filename: filename ?? '' });
if (subfolder && subfolder.trim()) {
params.set('subfolder', subfolder);
}
params.set('type', type || 'output');
return `${window.location.origin}/api/view?${params.toString()}`;
}
// Format file size for display
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
@@ -388,8 +408,6 @@ function formatFileSize(bytes) {
// Open image in new tab
function openImageInTab(imagePath, subfolder = '', enablePopup = true) {
console.log(`KikoSaveImage: Attempting to open image: ${imagePath}, subfolder: ${subfolder}`);
// Note: enablePopup parameter is kept for compatibility but not used
// since popup now controls viewer visibility, not individual image clicks
@@ -403,16 +421,12 @@ function openImageInTab(imagePath, subfolder = '', enablePopup = true) {
fullPath = `${basePath}/api/view?filename=${encodeURIComponent(imagePath)}&type=output`;
}
console.log(`KikoSaveImage: Opening URL: ${fullPath}`);
// Extract clean filename for window name (remove timestamp and batch number)
const cleanName = imagePath.split('_').slice(0, -2).join('_') || 'KikoSaveImage';
// Try to open the image with a clean window name
const newWindow = window.open(fullPath, cleanName.replace(/[^a-zA-Z0-9]/g, '_'));
if (newWindow) {
console.log('KikoSaveImage: Successfully opened new window');
} else {
if (!newWindow) {
console.error('KikoSaveImage: Failed to open new window - popup blocked?');
// Fallback: copy URL to clipboard and alert user
navigator.clipboard.writeText(fullPath).then(() => {
@@ -435,6 +449,10 @@ class KikoImageViewer extends HTMLElement {
this.dragOffset = { x: 0, y: 0 };
this.lastHeaderClick = 0;
// Bind document-level drag handlers once so they can be removed later
this.boundHandleDrag = this.handleDrag.bind(this);
this.boundEndDrag = this.endDrag.bind(this);
// Always start at default position
}
@@ -442,6 +460,16 @@ class KikoImageViewer extends HTMLElement {
return ['data'];
}
connectedCallback() {
document.addEventListener('mousemove', this.boundHandleDrag);
document.addEventListener('mouseup', this.boundEndDrag);
}
disconnectedCallback() {
document.removeEventListener('mousemove', this.boundHandleDrag);
document.removeEventListener('mouseup', this.boundEndDrag);
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'data' && newValue) {
try {
@@ -455,8 +483,6 @@ class KikoImageViewer extends HTMLElement {
}
setImageData(data) {
console.log('KikoImageViewer: setImageData called with:', data);
console.log('KikoImageViewer: First image data:', data[0]);
this.imageData = data;
this.autoUnrollOnNewImages();
this.render();
@@ -513,9 +539,7 @@ class KikoImageViewer extends HTMLElement {
});
});
// Global mouse events for dragging
document.addEventListener('mousemove', this.handleDrag.bind(this));
document.addEventListener('mouseup', this.endDrag.bind(this));
// Document-level drag listeners are managed in connectedCallback/disconnectedCallback
}
startDrag(e) {
@@ -697,12 +721,9 @@ class KikoImageViewer extends HTMLElement {
openAllImagesWithDelay() {
const selectedIndices = Array.from(this.selectedImages);
if (selectedIndices.length === 0) {
console.log('KikoSaveImage: No images selected for opening');
return;
}
console.log(`KikoSaveImage: Opening ${selectedIndices.length} images with delay`);
// Open images with 150ms delay between each to prevent popup blocking
selectedIndices.forEach((index, i) => {
setTimeout(() => {
@@ -716,12 +737,7 @@ class KikoImageViewer extends HTMLElement {
downloadImage(imageData) {
// Construct proper image URL handling subfolder
let imageUrl;
if (imageData.subfolder && imageData.subfolder.trim()) {
imageUrl = `${window.location.origin}/api/view?filename=${encodeURIComponent(imageData.filename)}&subfolder=${encodeURIComponent(imageData.subfolder)}&type=${imageData.type}`;
} else {
imageUrl = `${window.location.origin}/api/view?filename=${encodeURIComponent(imageData.filename)}&type=${imageData.type}`;
}
const imageUrl = buildViewUrl(imageData.filename, imageData.subfolder, imageData.type);
// Create temporary link and trigger download
const link = document.createElement('a');
@@ -748,7 +764,7 @@ class KikoImageViewer extends HTMLElement {
}
const header = this.imageData.length === 1
? `Saved Image (${this.imageData[0].format})`
? `Saved Image (${escapeHtml(this.imageData[0].format)})`
: `Saved Images (${this.imageData.length} files)`;
const containerStyle = this.isMinimized ? 'style="display: none;"' : '';
@@ -791,15 +807,10 @@ class KikoImageViewer extends HTMLElement {
createImageItem(data, index) {
// Construct proper image URL handling subfolder
let imageUrl;
if (data.subfolder && data.subfolder.trim()) {
// ComfyUI expects subfolder to be passed separately
imageUrl = `${window.location.origin}/api/view?filename=${encodeURIComponent(data.filename)}&subfolder=${encodeURIComponent(data.subfolder)}&type=${data.type}`;
} else {
imageUrl = `${window.location.origin}/api/view?filename=${encodeURIComponent(data.filename)}&type=${data.type}`;
}
// ComfyUI expects subfolder to be passed separately
const imageUrl = buildViewUrl(data.filename, data.subfolder, data.type);
const formatClass = `kiko-format-${data.format.toLowerCase()}`;
const formatClass = `kiko-format-${String(data.format ?? '').toLowerCase().replace(/[^a-z0-9_-]/g, '')}`;
// Format file size
const fileSize = data.file_size ? formatFileSize(data.file_size) : 'Unknown';
@@ -818,7 +829,7 @@ class KikoImageViewer extends HTMLElement {
return `
<div class="kiko-image-item" data-index="${index}">
<img src="${imageUrl}" alt="${data.filename}" loading="lazy" />
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(data.filename)}" loading="lazy" />
${this.imageData.length > 1 ? `
<div class="kiko-image-selector" data-index="${index}" title="Select image"></div>
@@ -829,14 +840,14 @@ class KikoImageViewer extends HTMLElement {
</div>
<div class="kiko-image-info">
<div class="kiko-image-filename" title="${data.filename}">
${data.filename}
<div class="kiko-image-filename" title="${escapeHtml(data.filename)}">
${escapeHtml(data.filename)}
</div>
<div class="kiko-image-details">
<div class="kiko-image-stats">
<span class="kiko-format-badge ${formatClass}">${data.format}</span>
${data.dimensions || 'Unknown'} • ${fileSize}
${qualityInfo ? ` • ${qualityInfo}` : ''}
<span class="kiko-format-badge ${formatClass}">${escapeHtml(data.format)}</span>
${escapeHtml(data.dimensions || 'Unknown')} • ${escapeHtml(fileSize)}
${qualityInfo ? ` • ${escapeHtml(qualityInfo)}` : ''}
</div>
</div>
</div>
@@ -852,7 +863,6 @@ class KikoImageViewer extends HTMLElement {
item.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log(`KikoImageViewer: Clicking image ${index}: ${data.filename}`);
openImageInTab(data.filename, data.subfolder || '');
});
}
@@ -872,8 +882,7 @@ function createImagePreview(imageData) {
// Create image element
const img = document.createElement('img');
const imagePath = `/api/view?filename=${imageData.filename}&type=${imageData.type}`;
img.src = imagePath;
img.src = buildViewUrl(imageData.filename, imageData.subfolder, imageData.type);
img.alt = imageData.filename;
// Create info overlay
@@ -1012,17 +1021,13 @@ app.registerExtension({
// Replace the standard image viewer with our custom web component
const originalOnExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(message) {
console.log('KikoSaveImage: onExecuted called with message:', message);
// Skip the original ComfyUI preview system
// Don't call originalOnExecuted to prevent default image display
// Use our custom web component instead
if (message && message.kiko_enhanced && message.kiko_enhanced.length > 0) {
console.log('KikoSaveImage: Creating custom image viewer');
this.createCustomImageViewer(message.kiko_enhanced);
} else {
console.log('KikoSaveImage: No enhanced data found, falling back to standard preview');
// Fall back to standard behavior if no enhanced data
if (originalOnExecuted) {
originalOnExecuted.apply(this, arguments);
@@ -1032,14 +1037,10 @@ app.registerExtension({
// Add method to create custom image viewer
nodeType.prototype.createCustomImageViewer = function(imageData) {
console.log('KikoSaveImage: Creating custom viewer for', imageData.length, 'images');
// Check if popup is enabled for any image (use first image's popup setting)
const popupEnabled = imageData.length > 0 ? imageData[0].popup : true;
console.log('KikoSaveImage: Popup enabled:', popupEnabled);
if (!popupEnabled) {
console.log('KikoSaveImage: Popup disabled, not showing custom viewer');
return;
}
@@ -1049,7 +1050,6 @@ app.registerExtension({
// Update existing viewer with new data
existingViewer.setImageData(imageData);
existingViewer.show(); // Make sure it's visible
console.log('KikoSaveImage: Updated existing viewer');
return;
}
@@ -1072,8 +1072,6 @@ app.registerExtension({
// Append to body (floating window) - always visible
document.body.appendChild(viewer);
console.log('KikoSaveImage: Custom viewer created successfully');
};
// Add method to create toggle button
@@ -1137,50 +1135,16 @@ app.registerExtension({
nodeElement.appendChild(toggleButton);
this.kikoToggleButton = toggleButton;
console.log('KikoSaveImage: Toggle button created');
};
// Legacy method kept for compatibility (not used with web component)
nodeType.prototype.enhanceImagePreviews = function(imageData) {
console.log('KikoSaveImage: Legacy enhanceImagePreviews called (should use web component instead)');
// No-op: superseded by the kiko-image-viewer web component
};
}
}
});
// Export helper functions to global scope for debugging
window.kikoSaveImageTest = function() {
console.log('KikoSaveImage: Testing click functionality...');
// Find all images in the document
const allImages = document.querySelectorAll('img');
console.log(`Found ${allImages.length} images in document`);
// Try to find images that look like our saved images
allImages.forEach((img, index) => {
console.log(`Image ${index}: src = ${img.src}`);
// Add test click handler to all images
img.style.border = '2px solid red';
img.style.cursor = 'pointer';
img.title = 'TEST: Click to open in new tab';
// Remove old handlers and add new one
const newImg = img.cloneNode(true);
img.parentNode.replaceChild(newImg, img);
newImg.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('TEST CLICK WORKED!', newImg.src);
window.open(newImg.src, '_blank');
}, true);
});
console.log('KikoSaveImage: Test setup complete. All images should now be clickable with red borders.');
};
// 🎉 RESET VIEWER WINDOWS 🎉
window.kikoResetViewer = function() {
console.log('🎉 Resetting KikoSaveImage viewer windows...');
@@ -1219,4 +1183,3 @@ console.log('🎉 KikoSaveImage: Extension loaded!');
console.log('💡 Helper functions available:');
console.log(' 📞 kikoResetViewer() - Remove existing viewer windows');
console.log(' 🚀 kikoForceViewer() - Show demo viewer');
console.log(' 🧪 kikoSaveImageTest() - Test image click functionality');
+3 -1
View File
@@ -448,8 +448,10 @@ const KikoWorkflowTimerExtension = {
KikoGlobalTimer.registerNode(this);
};
const origOnRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function() {
KikoGlobalTimer.unregisterNode(this);
return origOnRemoved?.apply(this, arguments);
};
const origOnSerialize = nodeType.prototype.onSerialize;
@@ -461,7 +463,7 @@ const KikoWorkflowTimerExtension = {
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
origOnConfigure?.apply(this, arguments);
this.properties = info.properties || {};
this.properties = { ...(this.properties || {}), ...(info?.properties || {}) };
if (this.timerDisplay) {
this.timerDisplay.textContent = this.properties.elapsed_time_str || "00:00:000";
+9 -8
View File
@@ -371,14 +371,15 @@ app.registerExtension({
});
const timeAgo = this.formatTimeAgo(item.timestamp);
entryDiv.innerHTML = `
<div style="color: #fff; font-weight: bold; margin-bottom: 1px;">
🎲 ${item.seed}
</div>
<div style="color: #999; font-size: 8px;">
⏰ ${timeAgo}
</div>
`;
// Build with textContent: history comes from localStorage and is untrusted
const seedDiv = document.createElement("div");
seedDiv.style.cssText = "color: #fff; font-weight: bold; margin-bottom: 1px;";
seedDiv.textContent = `🎲 ${Number(item.seed)}`;
const timeDiv = document.createElement("div");
timeDiv.style.cssText = "color: #999; font-size: 8px;";
timeDiv.textContent = `⏰ ${timeAgo}`;
entryDiv.appendChild(seedDiv);
entryDiv.appendChild(timeDiv);
this.historyDisplay.appendChild(entryDiv);
});