fix: escape image dimensions and workflow JSON before rendering

openGalleryModal interpolated width, height and file_size (stored from a
POST body, so possibly strings) straight into data-caption and the card
text. showWorkflowData wrote JSON.stringify(workflow) raw into a blank
same-origin window, so a workflow containing </pre><script> ran. Both now
go through ImageHelpers (new pure module, node:test covered); a string
file_size no longer reaches formatFileSize's toFixed either, and a blocked
popup shows a notice instead of throwing.
This commit is contained in:
Vito Sansevero
2026-10-08 18:01:34 -07:00
parent a3877b9200
commit e709a158e6
6 changed files with 150 additions and 27 deletions
+52
View File
@@ -0,0 +1,52 @@
// Run with: node --test tests/js/
const test = require("node:test");
const assert = require("node:assert/strict");
const { formatImageCaption, workflowDocumentHtml } = require("../../web/js/image-helpers.js");
const XSS = '"><img src=x onerror=1>';
const formatFileSize = (bytes) => `${bytes} B`;
test("formatImageCaption joins dimensions and size with the separator", () => {
const caption = formatImageCaption({ width: 640, height: 480, file_size: 2048 }, { formatFileSize });
assert.equal(caption, "640×480 | 2048 B");
});
test("formatImageCaption honours a custom separator and omits a missing size", () => {
assert.equal(formatImageCaption({ width: 1, height: 2 }, { formatFileSize, separator: " • " }), "1×2");
});
test("formatImageCaption falls back to Unknown size when a dimension is missing", () => {
assert.equal(formatImageCaption({ width: 640 }, { formatFileSize }), "Unknown size");
assert.equal(formatImageCaption({}, { formatFileSize }), "Unknown size");
assert.equal(formatImageCaption(null, { formatFileSize }), "Unknown size");
});
test("formatImageCaption escapes string dimensions so they cannot break out of an attribute", () => {
const caption = formatImageCaption({ width: XSS, height: 10 }, { formatFileSize });
assert.ok(!caption.includes("<"), caption);
assert.ok(!caption.includes('"'), caption);
assert.ok(caption.includes("&quot;&gt;&lt;img"), caption);
});
test("formatImageCaption ignores a non-numeric file_size instead of formatting it", () => {
const caption = formatImageCaption({ width: 1, height: 1, file_size: XSS }, { formatFileSize });
assert.equal(caption, "1×1");
});
test("formatImageCaption escapes whatever formatFileSize returns", () => {
const caption = formatImageCaption({ width: 1, height: 1, file_size: 5 }, { formatFileSize: () => "<b>" });
assert.equal(caption, "1×1 | &lt;b&gt;");
});
test("workflowDocumentHtml escapes the JSON so it cannot close the pre block", () => {
const html = workflowDocumentHtml({ note: "</pre><script>alert(1)</script>" });
assert.ok(html.includes("&lt;/pre&gt;&lt;script&gt;"), html);
assert.ok(!html.includes("</pre><script>"), html);
assert.ok(html.startsWith("<!DOCTYPE html>"));
assert.ok(html.includes("<title>ComfyUI Workflow Data</title>"));
});
test("workflowDocumentHtml pretty-prints the workflow with two-space indentation", () => {
const html = workflowDocumentHtml({ a: 1 });
assert.ok(html.includes('{\n &quot;a&quot;: 1\n}'), html);
});
+1
View File
@@ -6,6 +6,7 @@
<title>PromptManager - Admin Dashboard</title>
<script src="/prompt_manager/js/theme-bridge.js"></script>
<script src="/prompt_manager/js/html-escape.js"></script>
<script src="/prompt_manager/js/image-helpers.js"></script>
<script src="/prompt_manager/js/api-client.js"></script>
<script src="/prompt_manager/js/png-metadata.js"></script>
<script src="/prompt_manager/js/list-state.js"></script>
+1
View File
@@ -6,6 +6,7 @@
<title>PromptManager - Gallery</title>
<script src="/prompt_manager/js/theme-bridge.js"></script>
<script src="/prompt_manager/js/html-escape.js"></script>
<script src="/prompt_manager/js/image-helpers.js"></script>
<script src="/prompt_manager/js/api-client.js"></script>
<script src="/prompt_manager/js/png-metadata.js"></script>
<script src="/prompt_manager/js/list-state.js"></script>
+22 -18
View File
@@ -1434,14 +1434,21 @@
content.classList.remove("hidden");
empty.classList.add("hidden");
content.innerHTML = images.map((image, index) => `
const formatFileSize = (bytes) => this.formatFileSize(bytes);
content.innerHTML = images.map((image, index) => {
const generated = new Date(image.generation_time);
const when = escapeHtml(`${generated.toLocaleDateString()} ${generated.toLocaleTimeString()}`);
const fileUrl = `/prompt_manager/images/${escapeHtml(image.id)}/file`;
const caption = ImageHelpers.formatImageCaption(image, { formatFileSize });
const details = ImageHelpers.formatImageCaption(image, { formatFileSize, separator: ' • ' });
return `
<div class="group cursor-pointer bg-pm-surface rounded-pm-md overflow-hidden border border-pm hover:border-pm transition-all duration-200">
<div class="aspect-square bg-pm-surface overflow-hidden relative">
<img src="/prompt_manager/images/${image.id}/file"
alt="Generated image ${index + 1}"
<img src="${fileUrl}"
alt="Generated image ${index + 1}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
data-original="/prompt_manager/images/${image.id}/file"
data-caption="Generated: ${new Date(image.generation_time).toLocaleDateString()} ${new Date(image.generation_time).toLocaleTimeString()} | ${image.width && image.height ? `${image.width}×${image.height}` : 'Unknown size'}${image.file_size ? ` | ${this.formatFileSize(image.file_size)}` : ''}"
data-original="${fileUrl}"
data-caption="Generated: ${when} | ${caption}"
onerror="this.parentElement.innerHTML='<div class=\\'flex items-center justify-center h-full text-pm-secondary\\'>⚠️ Image not found</div>'">
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-opacity duration-300 flex items-center justify-center pointer-events-none">
<svg class="w-8 h-8 text-pm opacity-0 group-hover:opacity-100 transition-opacity duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -1451,15 +1458,15 @@
</div>
<div class="p-3">
<div class="text-xs text-pm-secondary mb-1">
${new Date(image.generation_time).toLocaleDateString()} ${new Date(image.generation_time).toLocaleTimeString()}
${when}
</div>
<div class="text-xs text-pm-muted">
${image.width && image.height ? `${image.width}×${image.height}` : 'Unknown size'}
${image.file_size ? ` • ${this.formatFileSize(image.file_size)}` : ''}
${details}
</div>
</div>
</div>
`).join('');
`;
}).join('');
// Store images for navigation
this.currentGalleryImages = images;
@@ -2856,15 +2863,12 @@
showWorkflowData() {
if (this.currentMetadata && this.currentMetadata.workflow) {
const newWindow = window.open('', '_blank');
newWindow.document.write(`
<html>
<head><title>ComfyUI Workflow Data</title></head>
<body style="background: #111; color: #fff; font-family: monospace; padding: 20px;">
<h2>ComfyUI Workflow JSON</h2>
<pre style="background: #222; padding: 15px; border-radius: 5px; overflow: auto;">${JSON.stringify(this.currentMetadata.workflow, null, 2)}</pre>
</body>
</html>
`);
if (!newWindow) {
this.showNotification('Popup blocked: allow popups to view the workflow', 'warning');
return;
}
newWindow.document.write(ImageHelpers.workflowDocumentHtml(this.currentMetadata.workflow));
newWindow.document.close();
}
}
+6 -9
View File
@@ -1461,15 +1461,12 @@
showWorkflowData() {
if (this.currentMetadata && this.currentMetadata.workflow) {
const newWindow = window.open('', '_blank');
newWindow.document.write(`
<html>
<head><title>ComfyUI Workflow Data</title></head>
<body style="background: #111; color: #fff; font-family: monospace; padding: 20px;">
<h2>ComfyUI Workflow JSON</h2>
<pre style="background: #222; padding: 15px; border-radius: 5px; overflow: auto;">${JSON.stringify(this.currentMetadata.workflow, null, 2)}</pre>
</body>
</html>
`);
if (!newWindow) {
this.showNotification('Popup blocked: allow popups to view the workflow', 'warning');
return;
}
newWindow.document.write(ImageHelpers.workflowDocumentHtml(this.currentMetadata.workflow));
newWindow.document.close();
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Pure helpers for image entries rendered by the admin and gallery pages.
*
* Image rows come from the database (`width`, `height`, `file_size` are stored
* from a POST body and may be strings) or from the output-folder scan (`id` is
* a path digest, `path` is the file). Everything these helpers return for HTML
* is already escaped, so callers can drop it into text or a quoted attribute.
*
* Loadable in the browser (window.ImageHelpers) and in Node for tests.
*/
(function (root) {
"use strict";
const escapeHtml =
typeof module !== "undefined" && module.exports
? require("./html-escape.js").escapeHtml
: root.escapeHtml;
const UNKNOWN_SIZE = "Unknown size";
/**
* "640×480 | 1.2 MB" for a card body or a viewer caption, escaped.
*
* @param {{width?: unknown, height?: unknown, file_size?: unknown}|null|undefined} image
* @param {{formatFileSize?: (bytes: number) => string, separator?: string}} [options]
* @returns {string} escaped text; "Unknown size" when a dimension is missing
*/
function formatImageCaption(image, { formatFileSize, separator = " | " } = {}) {
const entry = image || {};
const parts = [];
if (entry.width && entry.height) {
parts.push(`${escapeHtml(entry.width)}×${escapeHtml(entry.height)}`);
} else {
parts.push(UNKNOWN_SIZE);
}
const bytes = Number(entry.file_size);
if (formatFileSize && Number.isFinite(bytes) && bytes > 0) {
parts.push(escapeHtml(formatFileSize(bytes)));
}
return parts.join(escapeHtml(separator));
}
/**
* A complete standalone page showing a workflow as pretty-printed JSON.
* Written into a blank same-origin window, so the JSON must not be able
* to close the <pre> and inject markup.
*
* @param {unknown} workflow
* @returns {string} HTML document
*/
function workflowDocumentHtml(workflow) {
const json = escapeHtml(JSON.stringify(workflow, null, 2));
return [
"<!DOCTYPE html>",
"<html>",
"<head><meta charset=\"utf-8\"><title>ComfyUI Workflow Data</title></head>",
"<body style=\"background: #111; color: #fff; font-family: monospace; padding: 20px;\">",
"<h2>ComfyUI Workflow JSON</h2>",
`<pre style="background: #222; padding: 15px; border-radius: 5px; overflow: auto;">${json}</pre>`,
"</body>",
"</html>",
].join("\n");
}
const api = { formatImageCaption, workflowDocumentHtml };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.ImageHelpers = api;
})(typeof window !== "undefined" ? window : globalThis);