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:
@@ -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(""><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 | <b>");
|
||||
});
|
||||
|
||||
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("</pre><script>"), 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 "a": 1\n}'), html);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user