Escape untrusted Manager UI content
This commit is contained in:
+3
-1
@@ -16,5 +16,7 @@ comfyworkflows_sharekey
|
||||
github-stats-cache.json
|
||||
pip_overrides.json
|
||||
*.json
|
||||
# Track JSON test fixtures.
|
||||
!tests/cases/*.json
|
||||
check2.sh
|
||||
/venv/
|
||||
/venv/
|
||||
|
||||
+30
-23
@@ -304,7 +304,6 @@ print_comfyui_version()
|
||||
core.check_invalid_nodes()
|
||||
|
||||
|
||||
|
||||
def setup_environment():
|
||||
git_exe = core.get_config()['git_exe']
|
||||
|
||||
@@ -969,14 +968,22 @@ async def update_all(request):
|
||||
|
||||
|
||||
def convert_markdown_to_html(input_text):
|
||||
"""Parse source markdown, escaping text and URLs at their HTML boundaries."""
|
||||
pattern_a = re.compile(r'\[a/([^]]+)]\(([^)]+)\)')
|
||||
pattern_w = re.compile(r'\[w/([^]]+)]')
|
||||
pattern_i = re.compile(r'\[i/([^]]+)]')
|
||||
pattern_bold = re.compile(r'\*\*([^*]+)\*\*')
|
||||
pattern_white = re.compile(r'%%([^*]+)%%')
|
||||
|
||||
# Format link labels separately from URLs; escape URLs when inserted.
|
||||
hrefs = []
|
||||
|
||||
def replace_a(match):
|
||||
return f"<a href='{match.group(2)}' target='blank'>{match.group(1)}</a>"
|
||||
# Entities written in the source URL (e.g. &) retain their meaning.
|
||||
url = manager_util.unescape_html_entities(match.group(2))
|
||||
hrefs.append(manager_util.sanitize_url(url))
|
||||
text = manager_util.escape_html_text(match.group(1))
|
||||
return f"<a href='\x00H{len(hrefs) - 1}\x00' target='_blank' rel='noopener noreferrer'>{text}</a>"
|
||||
|
||||
def replace_w(match):
|
||||
return f"<p class='cm-warn-note'>{match.group(1)}</p>"
|
||||
@@ -990,20 +997,35 @@ def convert_markdown_to_html(input_text):
|
||||
def replace_white(match):
|
||||
return f"<font color='white'>{match.group(1)}</font>"
|
||||
|
||||
input_text = input_text.replace('\\[', '[').replace('\\]', ']').replace('<', '<').replace('>', '>')
|
||||
# NUL dropped first so the input cannot forge the href placeholders.
|
||||
input_text = input_text.replace('\x00', '')
|
||||
input_text = input_text.replace('\\[', '[').replace('\\]', ']')
|
||||
|
||||
result_text = re.sub(pattern_a, replace_a, input_text)
|
||||
# Parse links before escaping prose, so generated text entities never enter URLs.
|
||||
parts = []
|
||||
start = 0
|
||||
for match in pattern_a.finditer(input_text):
|
||||
parts.append(manager_util.sanitize_tag(input_text[start:match.start()]))
|
||||
parts.append(replace_a(match))
|
||||
start = match.end()
|
||||
parts.append(manager_util.sanitize_tag(input_text[start:]))
|
||||
result_text = ''.join(parts)
|
||||
result_text = re.sub(pattern_w, replace_w, result_text)
|
||||
result_text = re.sub(pattern_i, replace_i, result_text)
|
||||
result_text = re.sub(pattern_bold, replace_bold, result_text)
|
||||
result_text = re.sub(pattern_white, replace_white, result_text)
|
||||
result_text = result_text.replace("\n", "<BR>")
|
||||
|
||||
return result_text.replace("\n", "<BR>")
|
||||
return re.sub(
|
||||
r'\x00H(\d+)\x00',
|
||||
lambda m: manager_util.escape_html_attribute(hrefs[int(m.group(1))]),
|
||||
result_text,
|
||||
)
|
||||
|
||||
|
||||
def populate_markdown(x):
|
||||
if 'description' in x:
|
||||
x['description'] = convert_markdown_to_html(manager_util.sanitize_tag(x['description']))
|
||||
x['description'] = convert_markdown_to_html(x['description'])
|
||||
|
||||
if 'name' in x:
|
||||
x['name'] = manager_util.sanitize_tag(x['name'])
|
||||
@@ -1780,7 +1802,6 @@ async def set_db_mode_handler(request):
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
|
||||
@routes.get("/manager/policy/component")
|
||||
async def get_component_policy(request):
|
||||
return web.Response(text=core.get_config()['component_policy'], status=200)
|
||||
@@ -1834,19 +1855,6 @@ async def set_channel_url_list(request):
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
def add_target_blank(html_text):
|
||||
pattern = r'(<a\s+href="[^"]*"\s*[^>]*)(>)'
|
||||
|
||||
def add_target(match):
|
||||
if 'target=' not in match.group(1):
|
||||
return match.group(1) + ' target="_blank"' + match.group(2)
|
||||
return match.group(0)
|
||||
|
||||
modified_html = re.sub(pattern, add_target, html_text)
|
||||
|
||||
return modified_html
|
||||
|
||||
|
||||
@routes.get("/manager/notice")
|
||||
async def get_notice(request):
|
||||
url = "github.com"
|
||||
@@ -1862,7 +1870,8 @@ async def get_notice(request):
|
||||
match = pattern.search(html_content)
|
||||
|
||||
if match:
|
||||
markdown_content = match.group(1)
|
||||
# Sanitize remote HTML before appending Manager's version text.
|
||||
markdown_content = manager_util.sanitize_html_fragment(match.group(1))
|
||||
version_tag = os.environ.get('__COMFYUI_DESKTOP_VERSION__')
|
||||
if version_tag is not None:
|
||||
markdown_content += f"<HR>ComfyUI: {version_tag} [Desktop]"
|
||||
@@ -1876,8 +1885,6 @@ async def get_notice(request):
|
||||
# markdown_content += f"<BR> ()"
|
||||
markdown_content += f"<BR>Manager: {core.version_str}"
|
||||
|
||||
markdown_content = add_target_blank(markdown_content)
|
||||
|
||||
try:
|
||||
if '__COMFYUI_DESKTOP_VERSION__' not in os.environ:
|
||||
if core.comfy_ui_commit_datetime == datetime(1900, 1, 1, 0, 0, 0):
|
||||
|
||||
@@ -16,6 +16,8 @@ import logging
|
||||
import platform
|
||||
import shlex
|
||||
from functools import lru_cache
|
||||
from html import escape, unescape
|
||||
from html.entities import html5
|
||||
|
||||
|
||||
cache_lock = threading.Lock()
|
||||
@@ -268,6 +270,84 @@ def sanitize_tag(x):
|
||||
return x.replace('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
SAFE_URL_SCHEMES = frozenset({'http', 'https'})
|
||||
_URL_SCHEME_NOISE = re.compile(r'[\x00-\x20\x7f]')
|
||||
_URL_HEAD_DELIMITERS = ('/', '?', '#')
|
||||
|
||||
|
||||
def escape_html_attribute(value):
|
||||
"""Escape a value for a quoted HTML attribute."""
|
||||
return escape(str(value), quote=True)
|
||||
|
||||
|
||||
_HTML_ENTITY = re.compile(r'&(#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);')
|
||||
_HTML_TEXT_TOKEN = re.compile(_HTML_ENTITY.pattern + "|[&<>\"']")
|
||||
|
||||
|
||||
def unescape_html_entities(text):
|
||||
"""Decode complete entities once, leaving query keys such as ¬ebook intact."""
|
||||
def replace(match):
|
||||
entity = match.group(1)
|
||||
if entity.startswith('#'):
|
||||
return unescape(match.group(0))
|
||||
return html5.get(entity + ';', match.group(0))
|
||||
|
||||
return _HTML_ENTITY.sub(replace, text)
|
||||
|
||||
|
||||
def escape_html_text(value):
|
||||
"""Escape HTML text without double-escaping existing entities."""
|
||||
return _HTML_TEXT_TOKEN.sub(
|
||||
lambda m: m.group(0) if m.group(1) else escape_html_attribute(m.group(0)),
|
||||
str(value).replace('\x00', ''),
|
||||
)
|
||||
|
||||
|
||||
def sanitize_url(url):
|
||||
"""Allow HTTP(S), relative URLs and anchors.
|
||||
|
||||
HTML callers must also escape the result as an attribute."""
|
||||
raw = '' if url is None else str(url)
|
||||
probe = _URL_SCHEME_NOISE.sub('', raw)
|
||||
if not probe:
|
||||
return '#'
|
||||
|
||||
cut = len(probe)
|
||||
for delimiter in _URL_HEAD_DELIMITERS:
|
||||
found = probe.find(delimiter)
|
||||
if found != -1:
|
||||
cut = min(cut, found)
|
||||
head = probe[:cut]
|
||||
|
||||
if ':' in head:
|
||||
return raw.strip() if head.split(':', 1)[0].lower() in SAFE_URL_SCHEMES else '#'
|
||||
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def sanitize_html_fragment(fragment):
|
||||
"""Sanitize notice HTML, preserving its formatting and opening links safely."""
|
||||
# prestartup_script imports this module before installing dependencies.
|
||||
import nh3
|
||||
|
||||
return nh3.clean(
|
||||
'' if fragment is None else str(fragment),
|
||||
tags=nh3.ALLOWED_TAGS | {'font'},
|
||||
attributes={
|
||||
**nh3.ALLOWED_ATTRIBUTES,
|
||||
'*': {'class', 'title', 'align', 'width', 'height'},
|
||||
'font': {'color'},
|
||||
},
|
||||
# These elements previously hid their contents from the notice.
|
||||
clean_content_tags={
|
||||
'script', 'style', 'iframe', 'object', 'embed', 'svg', 'math',
|
||||
'template', 'noscript', 'textarea', 'title',
|
||||
},
|
||||
url_schemes=SAFE_URL_SCHEMES,
|
||||
set_tag_attribute_values={'a': {'target': '_blank'}},
|
||||
)
|
||||
|
||||
|
||||
def extract_package_as_zip(file_path, extract_path):
|
||||
import zipfile
|
||||
try:
|
||||
|
||||
@@ -14,7 +14,7 @@ import { OpenArtShareDialog } from "./comfyui-share-openart.js";
|
||||
import {
|
||||
free_models, install_pip, install_via_git_url, manager_instance,
|
||||
rebootAPI, setManagerInstance, show_message, customAlert, customPrompt,
|
||||
infoToast, showTerminal, setNeedRestart, handle403Response
|
||||
infoToast, showTerminal, setNeedRestart, handle403Response, sanitizeHTML, safeHref
|
||||
} from "./common.js";
|
||||
import { ComponentBuilderDialog, getPureName, load_components, set_component_policy } from "./components-manager.js";
|
||||
import { CustomNodesManager } from "./custom-nodes-manager.js";
|
||||
@@ -713,12 +713,12 @@ async function onQueueStatus(event) {
|
||||
for(let x in success_list) {
|
||||
let k = success_list[x];
|
||||
let url = event.detail.nodepack_result[k].url;
|
||||
let title = event.detail.nodepack_result[k].title;
|
||||
let title = sanitizeHTML(String(event.detail.nodepack_result[k].title ?? ''));
|
||||
if(url) {
|
||||
msg += `<li><a href='${url}' target='_blank'>${title}</a></li>`;
|
||||
msg += `<li><a href='${safeHref(url)}' target='_blank' rel='noopener noreferrer'>${title}</a></li>`;
|
||||
}
|
||||
else {
|
||||
msg += `<li>${k}</li>`;
|
||||
msg += `<li>${sanitizeHTML(String(k))}</li>`;
|
||||
}
|
||||
}
|
||||
msg += "</ul>";
|
||||
@@ -732,12 +732,12 @@ async function onQueueStatus(event) {
|
||||
for(let x in failed_list) {
|
||||
let k = failed_list[x];
|
||||
let url = event.detail.nodepack_result[k].url;
|
||||
let title = event.detail.nodepack_result[k].title;
|
||||
let title = sanitizeHTML(String(event.detail.nodepack_result[k].title ?? ''));
|
||||
if(url) {
|
||||
msg += `<li><a href='${url}' target='_blank'>${title}</a></li>`;
|
||||
msg += `<li><a href='${safeHref(url)}' target='_blank' rel='noopener noreferrer'>${title}</a></li>`;
|
||||
}
|
||||
else {
|
||||
msg += `<li>${k}</li>`;
|
||||
msg += `<li>${sanitizeHTML(String(k))}</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { $el, ComfyDialog } from "../../scripts/ui.js";
|
||||
import { CopusShareDialog } from "./comfyui-share-copus.js";
|
||||
import { OpenArtShareDialog } from "./comfyui-share-openart.js";
|
||||
import { YouMLShareDialog } from "./comfyui-share-youml.js";
|
||||
import { customAlert } from "./common.js";
|
||||
import { customAlert, safeHref, sanitizeHTML } from "./common.js";
|
||||
|
||||
export const SUPPORTED_OUTPUT_NODE_TYPES = [
|
||||
"PreviewImage",
|
||||
@@ -937,7 +937,7 @@ export class ShareDialog extends ComfyDialog {
|
||||
const response_json = await response.json();
|
||||
|
||||
if (response_json.comfyworkflows.url) {
|
||||
this.final_message.innerHTML = "Your art has been shared: <a href='" + response_json.comfyworkflows.url + "' target='_blank'>" + response_json.comfyworkflows.url + "</a>";
|
||||
this.final_message.innerHTML = "Your art has been shared: <a href='" + safeHref(response_json.comfyworkflows.url) + "' target='_blank' rel='noopener noreferrer'>" + sanitizeHTML(response_json.comfyworkflows.url) + "</a>";
|
||||
if (response_json.matrix.success) {
|
||||
this.final_message.innerHTML += "<br>Your art has been shared in the ComfyUI Matrix server's #share channel!";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { $el, ComfyDialog } from "../../scripts/ui.js";
|
||||
import { customAlert } from "./common.js";
|
||||
import { customAlert, safeHref } from "./common.js";
|
||||
|
||||
const env = "prod";
|
||||
|
||||
@@ -883,7 +883,7 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
const { data } = res.data;
|
||||
if (data) {
|
||||
const url = `${DEFAULT_HOMEPAGE_URL}/work/${data}`;
|
||||
this.message.innerHTML = `Workflow has been shared successfully. <a href="${url}" target="_blank">Click here to view it.</a>`;
|
||||
this.message.innerHTML = `Workflow has been shared successfully. <a href="${safeHref(url)}" target="_blank" rel="noopener noreferrer">Click here to view it.</a>`;
|
||||
this.previewImage.src = "";
|
||||
this.previewImage.style.display = "none";
|
||||
this.uploadedImages = [];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {app} from "../../scripts/app.js";
|
||||
import {api} from "../../scripts/api.js";
|
||||
import {ComfyDialog, $el} from "../../scripts/ui.js";
|
||||
import { customAlert } from "./common.js";
|
||||
import { customAlert, safeHref } from "./common.js";
|
||||
|
||||
const LOCAL_STORAGE_KEY = "openart_comfy_workflow_key";
|
||||
const DEFAULT_HOMEPAGE_URL = "https://openart.ai/workflows/dev?developer=true";
|
||||
@@ -511,7 +511,7 @@ export class OpenArtShareDialog extends ComfyDialog {
|
||||
const {workflow_id} = response.data;
|
||||
if (workflow_id) {
|
||||
const url = `https://openart.ai/workflows/-/-/${workflow_id}`;
|
||||
this.message.innerHTML = `Workflow has been shared successfully. <a href="${url}" target="_blank">Click here to view it.</a>`;
|
||||
this.message.innerHTML = `Workflow has been shared successfully. <a href="${safeHref(url)}" target="_blank" rel="noopener noreferrer">Click here to view it.</a>`;
|
||||
this.previewImage.src = "";
|
||||
this.previewImage.style.display = "none";
|
||||
this.uploadedImages = [];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {app} from "../../scripts/app.js";
|
||||
import {api} from "../../scripts/api.js";
|
||||
import {ComfyDialog, $el} from "../../scripts/ui.js";
|
||||
import { customAlert } from "./common.js";
|
||||
import { customAlert, safeHref } from "./common.js";
|
||||
|
||||
const BASE_URL = "https://youml.com";
|
||||
//const BASE_URL = "http://localhost:3000";
|
||||
@@ -420,7 +420,7 @@ export class YouMLShareDialog extends ComfyDialog {
|
||||
}
|
||||
}
|
||||
this.message.innerHTML = `${messagePrefix} To turn your workflow into an interactive app, ` +
|
||||
`<a href="${recipePageUrl}" target="_blank">visit it on YouML</a>`;
|
||||
`<a href="${safeHref(recipePageUrl)}" target="_blank" rel="noopener noreferrer">visit it on YouML</a>`;
|
||||
|
||||
this.uploadedImages = [];
|
||||
this.nameInput.value = "";
|
||||
|
||||
+42
-6
@@ -427,6 +427,40 @@ export function sanitizeHTML(str) {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Keep URL handling aligned with glob/manager_util.py; covered by shared cases.
|
||||
export const SAFE_URL_SCHEMES = ['http', 'https'];
|
||||
const URL_SCHEME_NOISE = /[\x00-\x20\x7f]/g;
|
||||
const URL_HEAD_DELIMITERS = ['/', '?', '#'];
|
||||
// Match Python str.strip(); JavaScript trim() uses different Unicode whitespace.
|
||||
const URL_TRIM = /^[\t-\r\x1c-\x20\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+|[\t-\r\x1c-\x20\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/g;
|
||||
const trimUrl = (value) => value.replace(URL_TRIM, '');
|
||||
|
||||
export function sanitizeUrl(url) {
|
||||
const raw = (url === null || url === undefined) ? '' : String(url);
|
||||
const probe = raw.replace(URL_SCHEME_NOISE, '');
|
||||
if (!probe) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
let cut = probe.length;
|
||||
for (const delimiter of URL_HEAD_DELIMITERS) {
|
||||
const found = probe.indexOf(delimiter);
|
||||
if (found !== -1) {
|
||||
cut = Math.min(cut, found);
|
||||
}
|
||||
}
|
||||
const head = probe.slice(0, cut);
|
||||
|
||||
if (head.includes(':')) {
|
||||
return SAFE_URL_SCHEMES.includes(head.split(':', 1)[0].toLowerCase()) ? trimUrl(raw) : '#';
|
||||
}
|
||||
|
||||
return trimUrl(raw);
|
||||
}
|
||||
|
||||
// Validate the URL, then escape it for a quoted href attribute.
|
||||
export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));
|
||||
|
||||
export function showTerminal() {
|
||||
try {
|
||||
const panel = app.extensionManager.bottomPanel;
|
||||
@@ -646,7 +680,9 @@ export async function uninstallNodes(nodeList, options = {}) {
|
||||
for (const nodeItem of nodeList) {
|
||||
target_items.push(nodeItem);
|
||||
|
||||
onProgress(`Uninstall ${nodeItem.title || nodeItem.name} ...`);
|
||||
// Titles are escaped by the caller; the name fallback is a raw pack key.
|
||||
const displayTitle = nodeItem.title || sanitizeHTML(String(nodeItem.name));
|
||||
onProgress(`Uninstall ${displayTitle} ...`);
|
||||
|
||||
const data = nodeItem.originalData || nodeItem;
|
||||
data.channel = channel;
|
||||
@@ -659,7 +695,7 @@ export async function uninstallNodes(nodeList, options = {}) {
|
||||
});
|
||||
|
||||
if (res.status != 200) {
|
||||
errorMsg = `'${sanitizeHTML(String(nodeItem.title || nodeItem.name))}': `;
|
||||
errorMsg = `'${displayTitle}': `;
|
||||
|
||||
if (res.status == 403) {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
@@ -988,14 +1024,14 @@ export function createFlyover(container, options = {}) {
|
||||
return flyover;
|
||||
}
|
||||
|
||||
// Shared UI State Methods - consolidated from multiple managers
|
||||
// Message methods accept HTML; callers escape raw data and preserve formatted content.
|
||||
export function createUIStateManager(element, selectors) {
|
||||
return {
|
||||
showSelection: (msg) => {
|
||||
const el = element.querySelector(selectors.selection);
|
||||
if (el) el.innerHTML = msg;
|
||||
},
|
||||
|
||||
|
||||
showError: (err) => {
|
||||
const el = element.querySelector(selectors.message);
|
||||
if (el) {
|
||||
@@ -1003,7 +1039,7 @@ export function createUIStateManager(element, selectors) {
|
||||
el.innerHTML = msg;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
showMessage: (msg, color) => {
|
||||
const el = element.querySelector(selectors.message);
|
||||
if (el) {
|
||||
@@ -1013,7 +1049,7 @@ export function createUIStateManager(element, selectors) {
|
||||
el.innerHTML = msg;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
showStatus: (msg, color) => {
|
||||
const el = element.querySelector(selectors.status);
|
||||
if (el) {
|
||||
|
||||
+42
-28
@@ -6,13 +6,13 @@ import { buildGuiFrameCustomHeader, createSettingsCombo } from "./comfyui-gui-b
|
||||
import {
|
||||
manager_instance, rebootAPI, install_via_git_url,
|
||||
fetchData, md5, icons, show_message, customConfirm, customAlert, customPrompt,
|
||||
sanitizeHTML, infoToast, showTerminal, setNeedRestart,
|
||||
sanitizeHTML, sanitizeUrl, infoToast, showTerminal, setNeedRestart,
|
||||
storeColumnWidth, restoreColumnWidth, getTimeAgo, copyText, loadCss,
|
||||
showPopover, hidePopover, getWorkflowNodeTypes, findPackageByCnrId, analyzeWorkflowUsage, createFlyover
|
||||
} from "./common.js";
|
||||
|
||||
// https://cenfun.github.io/turbogrid/api.html
|
||||
import TG from "./turbogrid.esm.js";
|
||||
import ManagerGrid from "./manager-grid.js";
|
||||
|
||||
loadCss("./custom-nodes-manager.css");
|
||||
|
||||
@@ -374,18 +374,17 @@ export class CustomNodesManager {
|
||||
}
|
||||
|
||||
let list = installGroups[action];
|
||||
if (!Array.isArray(list)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if(is_selected_button || rowItem?.version === "unknown") {
|
||||
list = list.filter(it => it !== "switch");
|
||||
}
|
||||
|
||||
if (!list) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return list.map(id => {
|
||||
const bt = buttons[id];
|
||||
return `<button class="cn-btn-${id} p-button p-component" group="${action}" mode="${bt.mode}">${bt.label}</button>`;
|
||||
return `<button class="cn-btn-${id} p-button p-component" group="${sanitizeHTML(String(action))}" mode="${bt.mode}">${bt.label}</button>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
@@ -528,7 +527,7 @@ export class CustomNodesManager {
|
||||
|
||||
initGrid() {
|
||||
const container = this.element.querySelector(".cn-manager-grid");
|
||||
const grid = new TG.Grid(container);
|
||||
const grid = new ManagerGrid(container);
|
||||
this.grid = grid;
|
||||
|
||||
this.flyover = createFlyover(container, {
|
||||
@@ -593,6 +592,9 @@ export class CustomNodesManager {
|
||||
|
||||
|
||||
grid.setOption({
|
||||
highlightKeywords: {
|
||||
textGenerator: (row, column) => column === 'author' ? sanitizeHTML(String(row[column] ?? '')) : row[column]
|
||||
},
|
||||
theme: 'dark',
|
||||
selectVisible: true,
|
||||
selectMultiple: true,
|
||||
@@ -698,7 +700,8 @@ export class CustomNodesManager {
|
||||
id: 'id',
|
||||
name: 'ID',
|
||||
width: 50,
|
||||
align: 'center'
|
||||
align: 'center',
|
||||
formatter: (value) => (value === null || value === undefined) ? value : sanitizeHTML(String(value))
|
||||
}, {
|
||||
id: 'title',
|
||||
name: 'Title',
|
||||
@@ -724,11 +727,13 @@ export class CustomNodesManager {
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
// DOM href takes a validated raw URL, without HTML attribute escaping.
|
||||
if(rowItem.originalData.repository)
|
||||
link.href = rowItem.originalData.repository;
|
||||
link.href = sanitizeUrl(rowItem.originalData.repository);
|
||||
else
|
||||
link.href = rowItem.reference;
|
||||
link.href = sanitizeUrl(rowItem.reference);
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.innerHTML = `<b>${title}</b>`;
|
||||
link.title = rowItem.originalData.id;
|
||||
container.appendChild(link);
|
||||
@@ -746,13 +751,15 @@ export class CustomNodesManager {
|
||||
if(!version) {
|
||||
return;
|
||||
}
|
||||
const safeVersion = sanitizeHTML(String(version));
|
||||
if(rowItem.cnr_latest && version != rowItem.cnr_latest) {
|
||||
const safeLatest = sanitizeHTML(String(rowItem.cnr_latest));
|
||||
if(version == 'nightly') {
|
||||
return `<div>${version}</div><div>[${rowItem.cnr_latest}]</div>`;
|
||||
return `<div>${safeVersion}</div><div>[${safeLatest}]</div>`;
|
||||
}
|
||||
return `<div>${version}</div><div>[↑${rowItem.cnr_latest}]</div>`;
|
||||
return `<div>${safeVersion}</div><div>[↑${safeLatest}]</div>`;
|
||||
}
|
||||
return version;
|
||||
return safeVersion;
|
||||
}
|
||||
}, {
|
||||
id: 'action',
|
||||
@@ -804,10 +811,11 @@ export class CustomNodesManager {
|
||||
width: 120,
|
||||
classMap: "cn-pack-author",
|
||||
formatter: (author, rowItem, columnItem) => {
|
||||
const safeAuthor = sanitizeHTML(String(author ?? ''));
|
||||
if (rowItem.trust) {
|
||||
return `<span title="This author has been active for more than six months in GitHub">✅ ${author}</span>`;
|
||||
return `<span title="This author has been active for more than six months in GitHub">✅ ${safeAuthor}</span>`;
|
||||
}
|
||||
return author;
|
||||
return safeAuthor;
|
||||
}
|
||||
}, {
|
||||
id: 'stars',
|
||||
@@ -821,7 +829,7 @@ export class CustomNodesManager {
|
||||
if (typeof stars === 'number') {
|
||||
return stars.toLocaleString();
|
||||
}
|
||||
return stars;
|
||||
return (stars === null || stars === undefined) ? stars : sanitizeHTML(String(stars));
|
||||
}
|
||||
}, {
|
||||
id: 'last_update',
|
||||
@@ -835,7 +843,7 @@ export class CustomNodesManager {
|
||||
return 'N/A';
|
||||
}
|
||||
const ago = getTimeAgo(last_update);
|
||||
const short = `${last_update}`.split(' ')[0];
|
||||
const short = sanitizeHTML(String(last_update).split(' ')[0]);
|
||||
return `<span title="${ago}">${short}</span>`;
|
||||
}
|
||||
}];
|
||||
@@ -1145,6 +1153,7 @@ export class CustomNodesManager {
|
||||
const rowItem = d.rowItem;
|
||||
const isNotInstalled = rowItem.action == "not-installed";
|
||||
|
||||
// Pack titles are already escaped by the server.
|
||||
let titleHtml = `<div class="cn-nodes-pack" hash="${rowItem.hash}">${rowItem.title}</div>`;
|
||||
if (isNotInstalled) {
|
||||
titleHtml += '<div class="cn-pack-badge">Not Installed</div>'
|
||||
@@ -1161,7 +1170,8 @@ export class CustomNodesManager {
|
||||
|
||||
list.push(`<div class="${rowClass}">`);
|
||||
list.push(`<div class="cn-nodes-sn">${i+1}</div>`);
|
||||
list.push(`<div class="cn-nodes-name">${it.name}</div>`);
|
||||
// extName via /customnode/getmappings, no server sanitize: escaped at the sink.
|
||||
list.push(`<div class="cn-nodes-name">${sanitizeHTML(String(it.name))}</div>`);
|
||||
|
||||
if (it.conflicts) {
|
||||
list.push(`<div class="cn-conflicts-list"><div class="cn-nodes-conflict cn-icon">${icons.conflicts}</div><b>Conflict with</b>${it.conflicts.map(c => {
|
||||
@@ -1283,7 +1293,7 @@ export class CustomNodesManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedMap = {};
|
||||
const selectedMap = Object.create(null);
|
||||
selectedList.forEach(item => {
|
||||
let type = item.action;
|
||||
if (item.restart) {
|
||||
@@ -1302,7 +1312,7 @@ export class CustomNodesManager {
|
||||
Object.keys(selectedMap).forEach(v => {
|
||||
const filterItem = this.getFilterItem(v);
|
||||
list.push(`<div class="cn-selected-buttons">
|
||||
<span>Selected <b>${selectedMap[v].length}</b> ${filterItem ? filterItem.label : v}</span>
|
||||
<span>Selected <b>${selectedMap[v].length}</b> ${sanitizeHTML(String(filterItem ? filterItem.label : v))}</span>
|
||||
${this.grid.hasMask ? "" : this.getActionButtons(v, null, true)}
|
||||
</div>`);
|
||||
});
|
||||
@@ -1467,7 +1477,8 @@ export class CustomNodesManager {
|
||||
});
|
||||
|
||||
if (res.status != 200) {
|
||||
errorMsg = `'${sanitizeHTML(String(item.title))}': `;
|
||||
// The title is already escaped by populate_markdown.
|
||||
errorMsg = `'${item.title}': `;
|
||||
|
||||
if(res.status == 403) {
|
||||
try {
|
||||
@@ -1561,7 +1572,8 @@ export class CustomNodesManager {
|
||||
for (let hash in result) {
|
||||
let v = result[hash];
|
||||
if (v != 'success' && v != 'skip') {
|
||||
errorMsg += v + '\n';
|
||||
// Escape raw task errors for both HTML message destinations.
|
||||
errorMsg += sanitizeHTML(String(v)) + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1753,7 +1765,7 @@ export class CustomNodesManager {
|
||||
this.showStatus(`Loading missing nodes (${mode}) ...`);
|
||||
const res = await fetchData(`/customnode/getmappings?mode=${mode}`);
|
||||
if (res.error) {
|
||||
this.showError(`Failed to get custom node mappings: ${res.error}`);
|
||||
this.showError(`Failed to get custom node mappings: ${sanitizeHTML(String(res.error))}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1871,7 +1883,7 @@ export class CustomNodesManager {
|
||||
this.showStatus(`Loading alternatives (${mode}) ...`);
|
||||
const res = await fetchData(`/customnode/alternatives?mode=${mode}`);
|
||||
if (res.error) {
|
||||
this.showError(`Failed to get alternatives: ${res.error}`);
|
||||
this.showError(`Failed to get alternatives: ${sanitizeHTML(String(res.error))}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1887,8 +1899,9 @@ export class CustomNodesManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
// tags (alter-list.json) is not server-transformed, so escape before innerHTML.
|
||||
const tags = `${item.tags}`.split(",").map(tag => {
|
||||
return `<div>${tag.trim()}</div>`;
|
||||
return `<div>${sanitizeHTML(tag.trim())}</div>`;
|
||||
}).join("");
|
||||
|
||||
hashMap[custom_node.hash] = {
|
||||
@@ -1906,7 +1919,7 @@ export class CustomNodesManager {
|
||||
const result = await analyzeWorkflowUsage(this.custom_nodes);
|
||||
|
||||
if (!result.success) {
|
||||
this.showError(`Failed to get workflow data: ${result.error}`);
|
||||
this.showError(`Failed to get workflow data: ${sanitizeHTML(String(result.error))}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -1977,7 +1990,7 @@ export class CustomNodesManager {
|
||||
this.custom_nodes = node_packs;
|
||||
|
||||
if(this.channel !== 'default') {
|
||||
this.element.querySelector(".cn-manager-channel").innerHTML = `Channel: ${this.channel} (Incomplete list)`;
|
||||
this.element.querySelector(".cn-manager-channel").innerHTML = `Channel: ${sanitizeHTML(String(this.channel))} (Incomplete list)`;
|
||||
}
|
||||
|
||||
for (const k in node_packs) {
|
||||
@@ -2109,6 +2122,7 @@ export class CustomNodesManager {
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
// Messages accept HTML; see createUIStateManager in common.js.
|
||||
showSelection(msg) {
|
||||
this.element.querySelector(".cn-manager-selection").innerHTML = msg;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Grid } from "./turbogrid.esm.js";
|
||||
|
||||
// Keep Manager's search hardening outside the vendored TurboGrid bundle.
|
||||
export default class ManagerGrid extends Grid {
|
||||
highlightKeywordsFilter(rowItem, columns, value) {
|
||||
const { textKey, textGenerator, highlightKey } = this.options.highlightKeywords;
|
||||
for (const column of columns) {
|
||||
rowItem[`${highlightKey}${column}`] = null;
|
||||
}
|
||||
const keywords = value ? String(value).trim().toLowerCase().split(/\s+/).filter(Boolean) : [];
|
||||
if (!keywords.length) return true;
|
||||
|
||||
let matched = false;
|
||||
for (const column of columns) {
|
||||
const value = typeof textGenerator === "function" ? textGenerator(rowItem, column) : rowItem[column];
|
||||
if (value === null || value === undefined) continue;
|
||||
let text = String(value).trim();
|
||||
if (!text) continue;
|
||||
const key = `${textKey}${column}`;
|
||||
if (rowItem[key] === null || rowItem[key] === undefined) {
|
||||
// A detached element's innerHTML can still run image handlers.
|
||||
rowItem[key] = new DOMParser().parseFromString(text, "text/html").body.textContent;
|
||||
}
|
||||
text = rowItem[key].toLowerCase();
|
||||
let offset = 0;
|
||||
const found = keywords.every(keyword => {
|
||||
const index = text.indexOf(keyword, offset);
|
||||
if (index === -1) return false;
|
||||
offset = index + keyword.length;
|
||||
return true;
|
||||
});
|
||||
if (found) {
|
||||
rowItem[`${highlightKey}${column}`] = true;
|
||||
this.highlightKeywords = keywords;
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
highlightTextNodes(nodes, keywords) {
|
||||
if (!keywords.length) return;
|
||||
let keywordIndex = 0;
|
||||
for (const node of nodes) {
|
||||
const text = node.textContent;
|
||||
const lower = text.toLowerCase();
|
||||
const wrapper = document.createElement("span");
|
||||
let offset = 0;
|
||||
while (offset < text.length) {
|
||||
const keyword = keywords[keywordIndex];
|
||||
const index = lower.indexOf(keyword, offset);
|
||||
if (index === -1) break;
|
||||
wrapper.append(text.slice(offset, index));
|
||||
const mark = document.createElement("mark");
|
||||
mark.textContent = text.slice(index, index + keyword.length);
|
||||
wrapper.append(mark);
|
||||
offset = index + keyword.length;
|
||||
keywordIndex = (keywordIndex + 1) % keywords.length;
|
||||
}
|
||||
if (wrapper.childNodes.length) {
|
||||
// Decoded cell text stays in text nodes, including inside <mark>.
|
||||
wrapper.append(text.slice(offset));
|
||||
node.replaceWith(wrapper);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
-23
@@ -3,18 +3,28 @@ import { $el } from "../../scripts/ui.js";
|
||||
import {
|
||||
manager_instance, rebootAPI,
|
||||
fetchData, md5, icons, show_message, customAlert, infoToast, showTerminal,
|
||||
storeColumnWidth, restoreColumnWidth, loadCss, formatSize, sizeToBytes
|
||||
storeColumnWidth, restoreColumnWidth, loadCss, formatSize, sizeToBytes,
|
||||
sanitizeHTML, safeHref
|
||||
} from "./common.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
// https://cenfun.github.io/turbogrid/api.html
|
||||
import TG from "./turbogrid.esm.js";
|
||||
import ManagerGrid from "./manager-grid.js";
|
||||
import { buildGuiFrameCustomHeader, createSettingsCombo } from "./comfyui-gui-builder.js";
|
||||
|
||||
loadCss("./model-manager.css");
|
||||
|
||||
const gridId = "model";
|
||||
|
||||
// Escape raw cells while preserving empty values.
|
||||
const escapeCell = (value) => (value === null || value === undefined) ? value : sanitizeHTML(String(value));
|
||||
|
||||
// Escape option markup while comparing the original values.
|
||||
const renderOptions = (list, current) => list.map(item => {
|
||||
const selected = item.value === current ? " selected" : "";
|
||||
return `<option value="${sanitizeHTML(String(item.value))}"${selected}>${sanitizeHTML(String(item.label))}</option>`;
|
||||
}).join("");
|
||||
|
||||
const pageHtml = `
|
||||
<div class="cmm-manager cmm-manager-dark">
|
||||
<div class="cmm-manager-grid"></div>
|
||||
@@ -101,22 +111,13 @@ export class ModelManager {
|
||||
|
||||
updateFilter() {
|
||||
const $filter = this.element.querySelector(".cmm-manager-filter");
|
||||
$filter.innerHTML = this.filterList.map(item => {
|
||||
const selected = item.value === this.filter ? " selected" : "";
|
||||
return `<option value="${item.value}"${selected}>${item.label}</option>`
|
||||
}).join("");
|
||||
$filter.innerHTML = renderOptions(this.filterList, this.filter);
|
||||
|
||||
const $type = this.element.querySelector(".cmm-manager-type");
|
||||
$type.innerHTML = this.typeList.map(item => {
|
||||
const selected = item.value === this.type ? " selected" : "";
|
||||
return `<option value="${item.value}"${selected}>${item.label}</option>`
|
||||
}).join("");
|
||||
$type.innerHTML = renderOptions(this.typeList, this.type);
|
||||
|
||||
const $base = this.element.querySelector(".cmm-manager-base");
|
||||
$base.innerHTML = this.baseList.map(item => {
|
||||
const selected = item.value === this.base ? " selected" : "";
|
||||
return `<option value="${item.value}"${selected}>${item.label}</option>`
|
||||
}).join("");
|
||||
$base.innerHTML = renderOptions(this.baseList, this.base);
|
||||
|
||||
}
|
||||
|
||||
@@ -199,7 +200,7 @@ export class ModelManager {
|
||||
|
||||
initGrid() {
|
||||
const container = this.element.querySelector(".cmm-manager-grid");
|
||||
const grid = new TG.Grid(container);
|
||||
const grid = new ManagerGrid(container);
|
||||
this.grid = grid;
|
||||
|
||||
grid.bind('onUpdated', (e, d) => {
|
||||
@@ -227,6 +228,9 @@ export class ModelManager {
|
||||
});
|
||||
|
||||
grid.setOption({
|
||||
highlightKeywords: {
|
||||
textGenerator: (row, column) => ['name', 'description'].includes(column) ? row[column] : escapeCell(row[column])
|
||||
},
|
||||
theme: 'dark',
|
||||
|
||||
selectVisible: true,
|
||||
@@ -325,7 +329,8 @@ export class ModelManager {
|
||||
maxWidth: 500,
|
||||
classMap: 'cmm-node-name',
|
||||
formatter: function(name, rowItem, columnItem, cellNode) {
|
||||
return `<a href=${rowItem.reference} target="_blank"><b>${name}</b></a>`;
|
||||
// Names are server-escaped; raw references need URL and attribute handling.
|
||||
return `<a href="${safeHref(rowItem.reference)}" target="_blank" rel="noopener noreferrer"><b>${name}</b></a>`;
|
||||
}
|
||||
}, {
|
||||
id: 'installed',
|
||||
@@ -351,7 +356,7 @@ export class ModelManager {
|
||||
sortable: false,
|
||||
align: 'center',
|
||||
formatter: (url, rowItem, columnItem) => {
|
||||
return `<a class="cmm-btn-download" title="Download file" href="${url}" target="_blank">${icons.download}</a>`;
|
||||
return `<a class="cmm-btn-download" title="Download file" href="${safeHref(url)}" target="_blank" rel="noopener noreferrer">${icons.download}</a>`;
|
||||
}
|
||||
}, {
|
||||
id: 'size',
|
||||
@@ -366,11 +371,14 @@ export class ModelManager {
|
||||
}, {
|
||||
id: 'type',
|
||||
name: 'Type',
|
||||
width: 100
|
||||
width: 100,
|
||||
formatter: escapeCell
|
||||
}, {
|
||||
id: 'base',
|
||||
name: 'Base'
|
||||
name: 'Base',
|
||||
formatter: escapeCell
|
||||
}, {
|
||||
// Descriptions are formatted HTML from the server.
|
||||
id: 'description',
|
||||
name: 'Description',
|
||||
width: 400,
|
||||
@@ -379,11 +387,13 @@ export class ModelManager {
|
||||
}, {
|
||||
id: "save_path",
|
||||
name: 'Save Path',
|
||||
width: 200
|
||||
width: 200,
|
||||
formatter: escapeCell
|
||||
}, {
|
||||
id: 'filename',
|
||||
name: 'Filename',
|
||||
width: 200
|
||||
width: 200,
|
||||
formatter: escapeCell
|
||||
}];
|
||||
|
||||
restoreColumnWidth(gridId, columns);
|
||||
@@ -458,6 +468,7 @@ export class ModelManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Model names are already escaped by the server.
|
||||
this.showStatus(`Install ${item.name} ...`);
|
||||
|
||||
const data = item.originalData;
|
||||
@@ -483,7 +494,7 @@ export class ModelManager {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
}
|
||||
} else {
|
||||
errorMsg += await res.text() + '\n';
|
||||
errorMsg += sanitizeHTML(await res.text()) + '\n';
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -552,8 +563,9 @@ export class ModelManager {
|
||||
for(let hash in result){
|
||||
let v = result[hash];
|
||||
|
||||
// Escape raw task errors for both HTML message destinations.
|
||||
if(v != 'success')
|
||||
errorMsg += v + '\n';
|
||||
errorMsg += sanitizeHTML(String(v)) + '\n';
|
||||
}
|
||||
|
||||
for(let k in self.install_context.targets) {
|
||||
@@ -670,6 +682,7 @@ export class ModelManager {
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
// Messages accept HTML; see createUIStateManager in common.js.
|
||||
showSelection(msg) {
|
||||
this.element.querySelector(".cmm-manager-selection").innerHTML = msg;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
manager_instance,
|
||||
fetchData, md5, show_message, customAlert, infoToast, showTerminal,
|
||||
storeColumnWidth, restoreColumnWidth, loadCss, uninstallNodes,
|
||||
analyzeWorkflowUsage, sizeToBytes, createFlyover, createUIStateManager
|
||||
analyzeWorkflowUsage, sizeToBytes, createFlyover, createUIStateManager,
|
||||
sanitizeHTML, safeHref
|
||||
} from "./common.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
@@ -220,7 +221,8 @@ export class NodeUsageAnalyzer {
|
||||
maxWidth: 500,
|
||||
classMap: 'nu-pack-name',
|
||||
formatter: function (name, rowItem, columnItem, cellNode) {
|
||||
return `<a href=${rowItem.reference} target="_blank"><b>${name}</b></a>`;
|
||||
// Names are escaped during loadData; references need URL and attribute handling.
|
||||
return `<a href="${safeHref(rowItem.reference)}" target="_blank" rel="noopener noreferrer"><b>${name}</b></a>`;
|
||||
}
|
||||
}, {
|
||||
id: 'used_in_count',
|
||||
@@ -283,7 +285,8 @@ export class NodeUsageAnalyzer {
|
||||
workflowList.forEach((workflow, i) => {
|
||||
list.push(`<div class="cn-nodes-row">`);
|
||||
list.push(`<div class="cn-nodes-sn">${i + 1}</div>`);
|
||||
list.push(`<div class="cn-nodes-name">${workflow.filename}</div>`);
|
||||
// Workflow filenames are raw server data.
|
||||
list.push(`<div class="cn-nodes-name">${sanitizeHTML(String(workflow.filename))}</div>`);
|
||||
list.push(`<div class="cn-nodes-details">${workflow.nodeCount} node${workflow.nodeCount > 1 ? 's' : ''}</div>`);
|
||||
list.push(`</div>`);
|
||||
});
|
||||
@@ -346,7 +349,8 @@ export class NodeUsageAnalyzer {
|
||||
target_items.push(item);
|
||||
|
||||
|
||||
this.ui.showStatus(`Install ${item.name} ...`);
|
||||
// Here item.name is a raw pack key, not a server-escaped title.
|
||||
this.ui.showStatus(`Install ${sanitizeHTML(String(item.name))} ...`);
|
||||
|
||||
const data = item.originalData;
|
||||
data.ui_id = item.hash;
|
||||
@@ -357,12 +361,13 @@ export class NodeUsageAnalyzer {
|
||||
});
|
||||
|
||||
if (res.status != 200) {
|
||||
errorMsg = `'${item.name}': `;
|
||||
// Escape raw task errors for both HTML message destinations.
|
||||
errorMsg = `'${sanitizeHTML(String(item.name))}': `;
|
||||
|
||||
if (res.status == 403) {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
} else {
|
||||
errorMsg += await res.text() + '\n';
|
||||
errorMsg += sanitizeHTML(await res.text()) + '\n';
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -466,8 +471,9 @@ export class NodeUsageAnalyzer {
|
||||
for (let hash in result) {
|
||||
let v = result[hash];
|
||||
|
||||
// Escape raw task errors for both HTML message destinations.
|
||||
if (v != 'success' && v != 'skip')
|
||||
errorMsg += v + '\n';
|
||||
errorMsg += sanitizeHTML(String(v)) + '\n';
|
||||
}
|
||||
|
||||
for (let k in self.install_context.targets) {
|
||||
@@ -591,7 +597,7 @@ export class NodeUsageAnalyzer {
|
||||
if (result.error.toString().includes('204')) {
|
||||
this.showMessage("No workflows were found for analysis.");
|
||||
} else {
|
||||
this.showError(result.error);
|
||||
this.showError(sanitizeHTML(String(result.error)));
|
||||
this.hideLoading();
|
||||
return;
|
||||
}
|
||||
@@ -612,7 +618,8 @@ export class NodeUsageAnalyzer {
|
||||
const workflowDetails = result.workflowDetailsMap?.get(packKey) || [];
|
||||
|
||||
models.push({
|
||||
title: pack.title || packKey,
|
||||
// Preserve server-escaped titles; escape only the raw pack-key fallback.
|
||||
title: pack.title || sanitizeHTML(String(packKey)),
|
||||
reference: pack.reference || pack.files?.[0] || '#',
|
||||
used_in_count: usedCount,
|
||||
workflowDetails: workflowDetails,
|
||||
@@ -639,6 +646,7 @@ export class NodeUsageAnalyzer {
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
// Messages accept HTML; see createUIStateManager in common.js.
|
||||
showSelection(msg) {
|
||||
this.element.querySelector(".nu-manager-selection").innerHTML = msg;
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js"
|
||||
import { ComfyDialog, $el } from "../../scripts/ui.js";
|
||||
import { manager_instance, rebootAPI, show_message, handle403Response, loadCss } from "./common.js";
|
||||
import { manager_instance, rebootAPI, show_message, handle403Response, loadCss, sanitizeHTML } from "./common.js";
|
||||
import { buildGuiFrame } from "./comfyui-gui-builder.js";
|
||||
|
||||
loadCss("./snapshot.css");
|
||||
@@ -133,7 +133,7 @@ export class SnapshotManager extends ComfyDialog {
|
||||
startRestore(target) {
|
||||
const self = SnapshotManager.instance;
|
||||
|
||||
self.updateMessage(`<BR><font color="green">Restore snapshot '${target.name}'</font>`);
|
||||
self.updateMessage(`<BR><font color="green">Restore snapshot '${sanitizeHTML(target.name)}'</font>`);
|
||||
|
||||
for(let i in self.restore_buttons) {
|
||||
self.restore_buttons[i].disabled = true;
|
||||
@@ -214,7 +214,7 @@ export class SnapshotManager extends ComfyDialog {
|
||||
data1.style.textAlign = "center";
|
||||
data1.innerHTML = i+1;
|
||||
var data2 = document.createElement('td');
|
||||
data2.innerHTML = ` ${data}`;
|
||||
data2.innerHTML = ` ${sanitizeHTML(data)}`;
|
||||
var data_button = document.createElement('td');
|
||||
data_button.style.textAlign = "center";
|
||||
data_button.className = "data-btns";
|
||||
|
||||
+21
-5
@@ -455,16 +455,32 @@ except Exception as e:
|
||||
|
||||
|
||||
def ensure_dependencies():
|
||||
requirements_path = os.path.join(os.path.dirname(__file__), "requirements.txt")
|
||||
try:
|
||||
import git # noqa: F401
|
||||
import toml # noqa: F401
|
||||
import rich # noqa: F401
|
||||
import chardet # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
my_path = os.path.dirname(__file__)
|
||||
requirements_path = os.path.join(my_path, "requirements.txt")
|
||||
from importlib.metadata import version
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
print("## ComfyUI-Manager: installing dependencies. (GitPython)")
|
||||
# Check the declared version before loading the native extension, so an
|
||||
# older nh3 can be upgraded without loading its binary into this process.
|
||||
with open(requirements_path) as requirements:
|
||||
for line in requirements:
|
||||
line = re.split(r'\s+#', line.strip(), maxsplit=1)[0]
|
||||
if not line or line.startswith(('#', '-')):
|
||||
continue
|
||||
nh3_requirement = Requirement(line)
|
||||
if nh3_requirement.name.lower() == 'nh3':
|
||||
break
|
||||
else:
|
||||
raise ValueError('requirements.txt does not declare nh3')
|
||||
if version('nh3') not in nh3_requirement.specifier:
|
||||
raise ModuleNotFoundError(str(nh3_requirement))
|
||||
import nh3 # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
print("## ComfyUI-Manager: installing dependencies.")
|
||||
try:
|
||||
subprocess.check_output(manager_util.make_pip_cmd(['install', '-r', requirements_path]))
|
||||
except subprocess.CalledProcessError:
|
||||
@@ -472,7 +488,7 @@ def ensure_dependencies():
|
||||
try:
|
||||
subprocess.check_output(manager_util.make_pip_cmd(['install', '--user', '-r', requirements_path]))
|
||||
except subprocess.CalledProcessError:
|
||||
print("## [ERROR] ComfyUI-Manager: Failed to install the GitPython package in the correct Python environment. Please install it manually in the appropriate environment. (You can seek help at https://app.element.io/#/room/%23comfyui_space%3Amatrix.org)")
|
||||
print("## [ERROR] ComfyUI-Manager: Failed to install required packages in the correct Python environment. Please install requirements.txt manually in the appropriate environment. (You can seek help at https://app.element.io/#/room/%23comfyui_space%3Amatrix.org)")
|
||||
|
||||
try:
|
||||
print("## ComfyUI-Manager: installing dependencies done.")
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ description = "ComfyUI-Manager provides features to install and manage custom no
|
||||
version = "3.41"
|
||||
license = { file = "LICENSE.txt" }
|
||||
requires-python = ">=3.9"
|
||||
dependencies = ["GitPython", "PyGithub", "matrix-nio", "transformers", "huggingface-hub>0.20", "typer", "rich", "typing-extensions", "toml", "uv", "chardet"]
|
||||
dependencies = ["GitPython", "PyGithub", "matrix-nio", "transformers", "huggingface-hub>0.20", "typer", "rich", "typing-extensions", "toml", "uv", "chardet", "packaging", "nh3>=0.3.7"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/ltdrdata/ComfyUI-Manager"
|
||||
|
||||
@@ -9,3 +9,5 @@ typing-extensions
|
||||
toml
|
||||
uv
|
||||
chardet
|
||||
packaging
|
||||
nh3>=0.3.7
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"note": "JS<->Python sanitize_url differential case matrix. STRING inputs only: Python str() and JS String() disagree for non-strings, so feeding them would report divergence for something that is not a defect. See the docstring of tests/test_url_sanitize_parity.py.",
|
||||
"provenance": "Handed over by gm3-review: 35 cases built to clear the WI-112 model-manager port, plus 22 built for the WI-120 review (scheme grammar, protocol-relative with userinfo, colons after a delimiter, unicode spaces and RTL override, degenerate schemes, a 500-char path). All 57 agreed on both implementations when handed over -- they are coverage, not known failures.",
|
||||
"type_coercion_warning": {
|
||||
"why": "Python str() and JS String() are NOT equivalent for non-strings. Feeding non-strings into the differential matrix produces FALSE divergence - a guard RED for a non-bug.",
|
||||
"measured": {
|
||||
"1.0": {
|
||||
"py": "1.0",
|
||||
"js": "1"
|
||||
},
|
||||
"True": {
|
||||
"py": "True",
|
||||
"js": "true"
|
||||
},
|
||||
"False": {
|
||||
"py": "False",
|
||||
"js": "false"
|
||||
},
|
||||
"[1,2]": {
|
||||
"py": "[1, 2]",
|
||||
"js": "1,2"
|
||||
},
|
||||
"{}": {
|
||||
"py": "{}",
|
||||
"js": "[object Object]"
|
||||
}
|
||||
},
|
||||
"aligned": {
|
||||
"0": "both '0'",
|
||||
"None/null": "both '#' - explicit null handling in both impls"
|
||||
},
|
||||
"recommendation": "Scope the matrix to STRING inputs and say so in the guard docstring; url is always a string off the JSON payload in practice."
|
||||
},
|
||||
"shared_matrix": [
|
||||
"javascript:a",
|
||||
"JaVaScRiPt:a",
|
||||
"java\tscript:a",
|
||||
"java\nscript:a",
|
||||
"\u0001javascript:a",
|
||||
" javascript:a",
|
||||
"data:text/html,x",
|
||||
"vbscript:a",
|
||||
"file:///etc/passwd",
|
||||
"about:blank",
|
||||
"blob:http://x",
|
||||
"javascript:a",
|
||||
"javascript:a",
|
||||
"javascript:a",
|
||||
"javascript:a",
|
||||
"http://e.com",
|
||||
"https://e.com/a?b=1&c=2",
|
||||
"HTTPS://E.COM",
|
||||
"/rel/path",
|
||||
"rel",
|
||||
"#anchor",
|
||||
"//proto/x",
|
||||
"a&b/c",
|
||||
"x&y?z=1",
|
||||
"e.com:8080/p",
|
||||
"foo:bar",
|
||||
":alert(1)",
|
||||
"",
|
||||
" ",
|
||||
"https://e.com/x\" onmouseover=\"y",
|
||||
"https://evil.example/a onmouseover=alert(1) x",
|
||||
"\\\\evil.com/x",
|
||||
"/\\evil.com",
|
||||
"\u0000javascript:a",
|
||||
"j\u0000avascript:a",
|
||||
"coap+tcp://h/x",
|
||||
"x-scheme://h",
|
||||
"a.b://h",
|
||||
"a-b://h",
|
||||
"HTTP+S://h",
|
||||
"//user:pass@evil.example/x",
|
||||
"#a:b",
|
||||
"?a=b:c",
|
||||
"/p:q",
|
||||
"http://h/#f:g",
|
||||
"http ://h",
|
||||
"javascript :a",
|
||||
"javascript:a",
|
||||
"http://h",
|
||||
"http://h/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"http://h/evil",
|
||||
"http:",
|
||||
"https:",
|
||||
"http:/h",
|
||||
":",
|
||||
"::",
|
||||
"a::b",
|
||||
" /rel/path ",
|
||||
" rel ",
|
||||
"\t#anchor\n",
|
||||
" http://h/x ",
|
||||
"\thttps://h/x\t",
|
||||
"http://h/x",
|
||||
"/rel/path",
|
||||
"https://h/x",
|
||||
"",
|
||||
" /rel ",
|
||||
"http://h/x
",
|
||||
"
http://h/x",
|
||||
"http://h/x\u001c",
|
||||
"http://h/x\u001d",
|
||||
"http://h/x\u001e",
|
||||
"http://h/x\u001f",
|
||||
"/rel/path
",
|
||||
"/rel/path\u001c",
|
||||
"https://h/x
",
|
||||
"http://h/x ",
|
||||
" http://h/x",
|
||||
"http://h/x ",
|
||||
"http://h/x ",
|
||||
"http://h/x ",
|
||||
"http://h/x
",
|
||||
"http://h/x
",
|
||||
"http://h/x ",
|
||||
"http://h/x ",
|
||||
"http://h/x ",
|
||||
" /rel",
|
||||
" /rel "
|
||||
],
|
||||
"gap_cases_found_by_enforcement": {
|
||||
"why": "Found by tests/test_url_sanitize_parity.py's own perturbation test, not by review: perturbing the JS scheme-less branch from `return raw.trim()` to `return raw` produced ZERO divergences against the handed-over 57, because every scheme-less case in that set is already trim-clean. The two trim call sites in sanitize_url were therefore unpinned on both sides.",
|
||||
"cases": [
|
||||
" /rel/path ",
|
||||
" rel ",
|
||||
"\t#anchor\n",
|
||||
" http://h/x ",
|
||||
"\thttps://h/x\t"
|
||||
]
|
||||
},
|
||||
"unicode_whitespace_divergence": {
|
||||
"why": "The 62-case matrix had no BOM or unicode-whitespace input, so the two trim call sites in sanitize_url were compared only over inputs whose edges are ASCII. They are NOT equivalent: String.prototype.trim() and str.strip() disagree on six codepoints.",
|
||||
"measured": {
|
||||
"js_strips_python_keeps": [
|
||||
"U+FEFF"
|
||||
],
|
||||
"python_strips_js_keeps": [
|
||||
"U+001C",
|
||||
"U+001D",
|
||||
"U+001E",
|
||||
"U+001F",
|
||||
"U+0085"
|
||||
],
|
||||
"method": "enumerated every codepoint below U+110000 on both sides: chr(c).isspace() in Python vs /\\s/-equivalent String.trim() in node; 24 shared, 6 divergent"
|
||||
},
|
||||
"example": "'http://h/x<U+FEFF>' -> python 'http://h/x<U+FEFF>', js 'http://h/x'",
|
||||
"resolution": "ALIGNED, not accepted. glob/manager_util.py is the declared source of truth for this pair (see test_the_guard_goes_red_when_either_allow_list_gains_a_scheme, which names it), so js/common.js stopped delegating to trim() and spells Python's str.strip() set out as URL_TRIM. The divergence is therefore a REGRESSION SHAPE this matrix now catches, not a gap_case.",
|
||||
"scope": "The trim only shapes the RETURNED string for an already-accepted URL. Scheme acceptance is decided on `probe`, which both sides build identically, so no case here changes what is allowed."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Lift real production JS out of the shipped files and execute it in node.
|
||||
|
||||
WHY LIFT RATHER THAN IMPORT: the manager's front-end modules pull in
|
||||
``../../scripts/app.js`` and touch ``document`` at module scope, so node cannot
|
||||
import them. WHY LIFT RATHER THAN RETYPE: a retyped copy of a formatter stops
|
||||
tracking the formatter the moment someone edits it, and then the guard passes
|
||||
while the shipped code regresses. Lifting keeps the executed bytes the shipped
|
||||
bytes — if the production function changes, so does the text under test.
|
||||
|
||||
This mirrors tests/test_manager_markdown_escaping.py, which AST-extracts
|
||||
``convert_markdown_to_html`` rather than importing manager_server.
|
||||
|
||||
Consumers point ``JsSource`` at a directory of .js files; an override directory
|
||||
(e.g. one populated with ``git show <base>:js/foo.js``) makes the RED half of a
|
||||
fix reproducible in one command.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def _skip_literal(source: str, pos: int) -> int:
|
||||
"""If a literal or comment starts at pos, return the index just past it.
|
||||
|
||||
Returns pos unchanged when nothing starts there. Template literals are
|
||||
consumed whole, which is safe for brace counting because a `${...}`
|
||||
substitution is itself balanced. A lone `/` is read as a regex literal —
|
||||
sanitizeHTML's own `.replace(/'/g, ...)` hides an apostrophe inside one, and
|
||||
no scanned range uses `/` as division. Were that to change, the assertion
|
||||
below fails loudly rather than returning a wrong slice.
|
||||
"""
|
||||
ch = source[pos]
|
||||
nxt = source[pos + 1] if pos + 1 < len(source) else ""
|
||||
if ch == "/" and nxt == "/":
|
||||
return source.index("\n", pos)
|
||||
if ch == "/" and nxt == "*":
|
||||
return source.index("*/", pos) + 2
|
||||
if ch not in "\"'`/":
|
||||
return pos
|
||||
cursor = pos + 1
|
||||
while cursor < len(source):
|
||||
if source[cursor] == "\\":
|
||||
cursor += 2
|
||||
continue
|
||||
if source[cursor] == ch:
|
||||
return cursor + 1
|
||||
cursor += 1
|
||||
raise AssertionError("unterminated literal at offset %d" % (pos,))
|
||||
|
||||
|
||||
def slice_braced(source: str, start_marker: str) -> str:
|
||||
"""Return start_marker plus the balanced {...} block that follows it."""
|
||||
start = source.index(start_marker)
|
||||
pos = source.index("{", start + len(start_marker))
|
||||
depth = 0
|
||||
while pos < len(source):
|
||||
skipped = _skip_literal(source, pos)
|
||||
if skipped != pos:
|
||||
pos = skipped
|
||||
continue
|
||||
if source[pos] == "{":
|
||||
depth += 1
|
||||
elif source[pos] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[start:pos + 1]
|
||||
pos += 1
|
||||
raise AssertionError("unbalanced braces after %r" % (start_marker,))
|
||||
|
||||
|
||||
def slice_object_entry(source: str, marker: str, indent: str = "\t\t") -> str:
|
||||
"""Return one object literal out of a `}, {`-chained array, by id marker.
|
||||
|
||||
These objects are literals inside an array, so the first `{` after the
|
||||
marker belongs to the NEXT entry — brace matching cannot be used. The entry
|
||||
instead ends at the first `}` sitting at the array's own indent.
|
||||
"""
|
||||
start = source.index(marker)
|
||||
end = source.index("\n%s}" % indent, start)
|
||||
return source[start:end]
|
||||
|
||||
|
||||
def line_containing(source: str, marker: str) -> str:
|
||||
"""Return the single source line that holds `marker` (fails if absent)."""
|
||||
start = source.index(marker)
|
||||
line_start = source.rfind("\n", 0, start) + 1
|
||||
return source[line_start:source.index("\n", start)]
|
||||
|
||||
|
||||
class JsSource:
|
||||
"""The shipped .js files under test, read from `js_dir`."""
|
||||
|
||||
def __init__(self, js_dir):
|
||||
self.js_dir = Path(js_dir)
|
||||
self._cache = {}
|
||||
|
||||
def text(self, filename: str) -> str:
|
||||
if filename not in self._cache:
|
||||
self._cache[filename] = (self.js_dir / filename).read_text(encoding="utf-8")
|
||||
return self._cache[filename]
|
||||
|
||||
def lift_declaration(self, filename: str, marker: str) -> str:
|
||||
"""Lift an `export function`/`export const` declaration, minus `export`."""
|
||||
return slice_braced(self.text(filename), marker).replace("export ", "", 1)
|
||||
|
||||
def lift_formatter(self, filename: str, anchor: str) -> str:
|
||||
"""Lift the `formatter: ...` at `anchor` as a bare function expression."""
|
||||
block = slice_braced(self.text(filename), anchor)
|
||||
marker = "formatter:"
|
||||
return block[block.index(marker) + len(marker):].strip()
|
||||
|
||||
def lift_span(self, filename: str, start_marker: str, end_marker: str) -> str:
|
||||
"""Lift the source between two markers, end_marker included."""
|
||||
source = self.text(filename)
|
||||
start = source.index(start_marker)
|
||||
end = source.index(end_marker, start) + len(end_marker)
|
||||
return source[start:end]
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
"""Execute an ES-module script and parse the single JSON object it prints."""
|
||||
assert NODE is not None, "node is required to execute the lifted production JS"
|
||||
result = subprocess.run(
|
||||
[NODE, "--input-type=module", "-e", script],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, "node failed:\n%s" % (result.stderr,)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
class Collector(HTMLParser):
|
||||
"""Collects the tags and attributes a browser would actually build."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.tags = []
|
||||
self.attrs = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.tags.append(tag)
|
||||
self.attrs.extend(attrs)
|
||||
|
||||
handle_startendtag = handle_starttag
|
||||
|
||||
|
||||
def parse(markup: str) -> Collector:
|
||||
collector = Collector()
|
||||
collector.feed(markup)
|
||||
collector.close()
|
||||
return collector
|
||||
|
||||
|
||||
def event_handlers(markup: str):
|
||||
"""Every on* attribute a browser would attach for this markup."""
|
||||
return [name for name, _ in parse(markup).attrs if name.lower().startswith("on")]
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Load Python escaping code without importing the ComfyUI server.
|
||||
|
||||
MANAGER_GLOB_DIR can point to another revision for regression checks. Loading
|
||||
manager_util by path avoids shadowing the standard library's glob module.
|
||||
"""
|
||||
import ast
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
GLOB_DIR = Path(os.environ.get("MANAGER_GLOB_DIR") or (Path(__file__).resolve().parent.parent / "glob"))
|
||||
|
||||
|
||||
def load_manager_util():
|
||||
path = GLOB_DIR / "manager_util.py"
|
||||
spec = importlib.util.spec_from_file_location("_manager_util_under_test", path)
|
||||
assert spec is not None and spec.loader is not None, f"cannot load {path}"
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def load_markdown_functions(manager_util):
|
||||
source = (GLOB_DIR / "manager_server.py").read_text(encoding="utf-8")
|
||||
names = {"convert_markdown_to_html", "populate_markdown"}
|
||||
functions = [node for node in ast.parse(source).body
|
||||
if isinstance(node, ast.FunctionDef) and node.name in names]
|
||||
assert len(functions) == len(names) and {node.name for node in functions} == names
|
||||
namespace = {"re": re, "manager_util": manager_util}
|
||||
exec(compile(ast.Module(body=functions, type_ignores=[]), "<markdown-extract>", "exec"), namespace)
|
||||
return namespace
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Exercise Manager's search overrides with the shipped TurboGrid in Chromium.
|
||||
|
||||
Browser cases require Playwright and its Chromium install. All requests are
|
||||
intercepted locally; no ComfyUI server or external services are used.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
JS = Path(os.environ.get("MANAGER_JS_DIR") or (Path(__file__).resolve().parent.parent / "js"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", ["custom-nodes-manager.js", "model-manager.js"])
|
||||
def test_searching_managers_use_the_safe_grid(filename):
|
||||
source = (JS / filename).read_text()
|
||||
assert 'import ManagerGrid from "./manager-grid.js"' in source
|
||||
assert "new ManagerGrid(container)" in source
|
||||
assert 'from "./turbogrid.esm.js"' not in source
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def browser():
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
with playwright.sync_playwright() as driver:
|
||||
if not Path(driver.chromium.executable_path).exists():
|
||||
pytest.skip("install Chromium with: playwright install chromium")
|
||||
instance = driver.chromium.launch()
|
||||
yield instance
|
||||
instance.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(browser):
|
||||
page = browser.new_page()
|
||||
|
||||
def route(request):
|
||||
name = request.request.url.rsplit("/", 1)[-1]
|
||||
if name in {"manager-grid.js", "turbogrid.esm.js"}:
|
||||
request.fulfill(path=str(JS / name), content_type="text/javascript")
|
||||
elif request.request.url == "http://manager.test/":
|
||||
request.fulfill(
|
||||
body='<div id="grid" style="width:800px;height:300px"></div>',
|
||||
content_type="text/html",
|
||||
)
|
||||
else:
|
||||
request.abort()
|
||||
|
||||
page.route("**/*", route)
|
||||
page.goto("http://manager.test/")
|
||||
page.evaluate("""async () => {
|
||||
const {default: ManagerGrid} = await import('/manager-grid.js');
|
||||
window.grid = new ManagerGrid(document.getElementById('grid'));
|
||||
grid.setData({columns: [{id: 'name', name: 'Name'}], rows: []});
|
||||
grid.render();
|
||||
}""")
|
||||
page.wait_for_function("window.grid.options !== undefined")
|
||||
yield page
|
||||
page.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("keyword", ["before", "img", "after"])
|
||||
def test_highlighting_keeps_all_text_inert(page, keyword):
|
||||
text = 'before <img src="/bad.png" onerror="window.__probe=1"> & after'
|
||||
result = page.evaluate("""({text, keyword}) => {
|
||||
const cell = document.createElement('div');
|
||||
cell.id = 'cell';
|
||||
cell.textContent = text;
|
||||
document.body.append(cell);
|
||||
grid.highlightKeywordsSync([cell], [keyword]);
|
||||
return {text: cell.textContent, marks: [...cell.querySelectorAll('mark')].map(x => x.textContent),
|
||||
images: cell.querySelectorAll('img').length};
|
||||
}""", {"text": text, "keyword": keyword})
|
||||
assert result == {"text": text, "marks": [keyword], "images": 0}
|
||||
assert page.evaluate("window.__probe === undefined")
|
||||
|
||||
|
||||
def test_keyword_order_continues_across_text_nodes(page):
|
||||
result = page.evaluate("""() => {
|
||||
const cell = document.createElement('div');
|
||||
cell.innerHTML = '<b>Alpha</b> beta Alpha beta <svg><text>Alpha</text></svg><textarea>beta</textarea>';
|
||||
grid.highlightKeywordsSync([cell], ['alpha', 'beta']);
|
||||
return [...cell.querySelectorAll('mark')].map(x => x.textContent);
|
||||
}""")
|
||||
assert result == ["Alpha", "beta", "Alpha", "beta"]
|
||||
|
||||
|
||||
def test_search_cache_does_not_execute_html(page):
|
||||
result = page.evaluate("""() => {
|
||||
const row = {description: '<img src="/bad.png" onerror="window.__probe=1"><b>Alpha</b> & Beta'};
|
||||
const matched = grid.highlightKeywordsFilter(row, ['description'], 'alpha & beta');
|
||||
return {matched, text: row.tg_text_description, highlight: row.tg_highlight_description};
|
||||
}""")
|
||||
assert result == {"matched": True, "text": "Alpha & Beta", "highlight": True}
|
||||
page.wait_for_timeout(50)
|
||||
assert page.evaluate("window.__probe === undefined")
|
||||
|
||||
|
||||
def test_search_preserves_order_columns_and_empty_query(page):
|
||||
result = page.evaluate("""() => {
|
||||
const row = {name: 'Alpha Beta', description: 'Gamma', missing: null, count: 0};
|
||||
const columns = ['name', 'description', 'missing', 'count'];
|
||||
const ordered = grid.highlightKeywordsFilter(row, columns, ' ALPHA beta ');
|
||||
const reversed = grid.highlightKeywordsFilter(row, columns, 'beta alpha');
|
||||
const acrossColumns = grid.highlightKeywordsFilter(row, columns, 'alpha gamma');
|
||||
const zero = grid.highlightKeywordsFilter(row, columns, '0');
|
||||
const empty = grid.highlightKeywordsFilter(row, columns, '');
|
||||
return {ordered, reversed, acrossColumns, zero, empty,
|
||||
flags: columns.map(column => row['tg_highlight_' + column])};
|
||||
}""")
|
||||
assert result == {"ordered": True, "reversed": False, "acrossColumns": False,
|
||||
"zero": True, "empty": True, "flags": [None] * 4}
|
||||
|
||||
|
||||
def test_custom_text_generator_and_cache_keys(page):
|
||||
result = page.evaluate("""() => {
|
||||
Object.assign(grid.options.highlightKeywords, {
|
||||
textKey: 'text_', highlightKey: 'mark_',
|
||||
textGenerator: (row, column) => row[column].label
|
||||
});
|
||||
const row = {name: {label: '<b>Model</b> <Flux>'}};
|
||||
const matched = grid.highlightKeywordsFilter(row, ['name'], 'model <flux>');
|
||||
return {matched, text: row.text_name, highlight: row.mark_name};
|
||||
}""")
|
||||
assert result == {"matched": True, "text": "Model <Flux>", "highlight": True}
|
||||
|
||||
|
||||
def test_grid_render_search_and_clear_keep_names_readable(page):
|
||||
page.evaluate("""() => {
|
||||
window.query = 'flux';
|
||||
grid.setOption({rowFilter: row => grid.highlightKeywordsFilter(row, ['name'], window.query)});
|
||||
grid.setData({columns: [{id: 'name', name: 'Name'}], rows: [
|
||||
{name: 'Model <Flux> <img src=x onerror=window.__probe=1>'},
|
||||
{name: 'Other Model'}
|
||||
]});
|
||||
grid.render();
|
||||
}""")
|
||||
page.wait_for_function("document.querySelectorAll('#grid mark').length === 1")
|
||||
assert page.locator("#grid mark").all_text_contents() == ["Flux"]
|
||||
assert page.evaluate("grid.viewRows.length") == 1
|
||||
assert page.locator("#grid img").count() == 0
|
||||
assert 'Model <Flux> <img src=x onerror=window.__probe=1>' in page.locator("#grid").inner_text()
|
||||
|
||||
page.evaluate("window.query = 'other'; grid.update()")
|
||||
page.wait_for_function("document.querySelector('#grid mark')?.textContent === 'Other'")
|
||||
assert page.evaluate("grid.viewRows.length") == 1
|
||||
|
||||
page.evaluate("window.query = ''; grid.update()")
|
||||
page.wait_for_function("grid.viewRows.length === 2 && !document.querySelector('#grid mark')")
|
||||
assert page.locator("#grid img").count() == 0
|
||||
assert page.evaluate("window.__probe === undefined")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,column,value,query,nonmatching", [
|
||||
("custom-nodes-manager.js", "title", "Pack <Flux>", "<flux>", "<flux>"),
|
||||
("custom-nodes-manager.js", "description", "Notes <Flux>", "<flux>", "<flux>"),
|
||||
("custom-nodes-manager.js", "author", "Writer <Alias>", "<alias>", "<alias>"),
|
||||
("custom-nodes-manager.js", "author", "Writer <Alias>", "<alias>", "<alias>"),
|
||||
("model-manager.js", "name", "Model <Flux>", "<flux>", "<flux>"),
|
||||
("model-manager.js", "description", "<b>Notes</b> <Flux>", "<flux>", "<flux>"),
|
||||
("model-manager.js", "filename", "model <Flux>", "<flux>", "<flux>"),
|
||||
("model-manager.js", "type", "Type <Flux>", "<flux>", "<flux>"),
|
||||
])
|
||||
def test_manager_search_matches_visible_text(page, filename, column, value, query, nonmatching):
|
||||
source = (JS / filename).read_text()
|
||||
init_body = source.split("\tinitGrid() {", 1)[1].split("\n\t}\n", 1)[0]
|
||||
common = (JS / "common.js").read_text()
|
||||
sanitizer = common.split("export function sanitizeHTML(str) {", 1)[1].split("\n}", 1)[0]
|
||||
escape_cell = "undefined"
|
||||
if filename == "model-manager.js":
|
||||
escape_cell = source.split("const escapeCell = ", 1)[1].split(";", 1)[0]
|
||||
page.evaluate("""({init_body, sanitizer, escape_cell, column, value, query}) => {
|
||||
const sanitizeHTML = new Function('str', sanitizer);
|
||||
const escapeCell = new Function('sanitizeHTML', `return (${escape_cell})`)(sanitizeHTML);
|
||||
const initGrid = new Function('ManagerGrid', 'createFlyover', 'gridId', 'sanitizeHTML', 'escapeCell',
|
||||
`return function initGrid() {${init_body}}`)(grid.constructor, () => ({}), 'review', sanitizeHTML, escapeCell);
|
||||
const element = document.createElement('div');
|
||||
element.innerHTML = '<div id="tested-grid" class="cn-manager-grid cmm-manager-grid" style="width:800px;height:300px"></div>';
|
||||
document.body.append(element);
|
||||
window.manager = {element, keywords: query, showStatus() {}, handleFlyoverHover() {}, createFlyover: () => ({}), hasAlternatives: () => false};
|
||||
initGrid.call(manager);
|
||||
manager.grid.setData({columns: [{id: column, name: column}], rows: [{[column]: value}]});
|
||||
manager.grid.render();
|
||||
}""", {"init_body": init_body, "sanitizer": sanitizer, "escape_cell": escape_cell,
|
||||
"column": column, "value": value, "query": query})
|
||||
page.wait_for_function("manager.grid.viewRows !== undefined")
|
||||
assert page.evaluate("manager.grid.viewRows.length") == 1
|
||||
page.evaluate("query => {manager.keywords=query;}", nonmatching)
|
||||
assert not page.evaluate("manager.grid.options.rowFilter(manager.grid.data.rows[0])")
|
||||
@@ -0,0 +1,331 @@
|
||||
"""RED->GREEN guard for the unescaped-innerHTML regression in the legacy Manager UI.
|
||||
|
||||
A node pack `description` is markdown-transformed server-side and then written to
|
||||
innerHTML by the client. `convert_markdown_to_html`'s `replace_a` interpolated the
|
||||
captured URL RAW into a single-quoted href, and the URL pattern is `([^)]+)` — so a
|
||||
URL carrying a single quote closed the attribute and injected an event handler.
|
||||
No `<` or `>` is needed, which is why the upstream `<`/`>` escaping did not stop it.
|
||||
|
||||
IMPORT DISCIPLINE (load-bearing, see tests/conftest.py):
|
||||
- `glob/` has NO `__init__.py`, so `import glob.manager_util` is not a package
|
||||
import, and putting `glob/` on sys.path would SHADOW the stdlib `glob` module
|
||||
and break pytest itself. manager_util is therefore loaded by FILE LOCATION
|
||||
under a private module name — it is the real module, really executed.
|
||||
- `glob/manager_server.py` starts a network thread at import and needs the
|
||||
ComfyUI stack, so it is NEVER imported. `convert_markdown_to_html` is
|
||||
AST-extracted and executed in an isolated namespace instead — the same
|
||||
technique tests/test_csrf_content_type_helper.py already uses in this repo.
|
||||
"""
|
||||
import unittest
|
||||
|
||||
from manager_test_utils import load_manager_util, load_markdown_functions
|
||||
|
||||
|
||||
def _parse_anchors(html):
|
||||
"""Return one dict of attributes per <a> tag, using a real HTML parser."""
|
||||
from html.parser import HTMLParser
|
||||
|
||||
class _Collector(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.anchors = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "a":
|
||||
self.anchors.append(dict(attrs))
|
||||
|
||||
collector = _Collector()
|
||||
collector.feed(html)
|
||||
collector.close()
|
||||
return collector.anchors
|
||||
|
||||
|
||||
def _parse_element_names(html):
|
||||
"""Return the tag name of every element the HTML actually produces."""
|
||||
from html.parser import HTMLParser
|
||||
|
||||
class _Collector(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.names = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.names.append(tag)
|
||||
|
||||
collector = _Collector()
|
||||
collector.feed(html)
|
||||
collector.close()
|
||||
return collector.names
|
||||
|
||||
|
||||
MANAGER_UTIL = load_manager_util()
|
||||
CONVERT = load_markdown_functions(MANAGER_UTIL)["convert_markdown_to_html"]
|
||||
|
||||
# Synthetic stand-in for the crafted payload. It keeps a LEGITIMATE https scheme
|
||||
# on purpose: a scheme allow-list alone does not stop this one, so the test pins
|
||||
# the attribute ESCAPING rather than the allow-list.
|
||||
BREAKOUT_URL = "https://example.com/x' onmouseover='alert`1`"
|
||||
BREAKOUT_MD = f"[a/click me]({BREAKOUT_URL})"
|
||||
|
||||
|
||||
class TestAttributeBreakout(unittest.TestCase):
|
||||
def test_quote_in_url_cannot_close_the_href_attribute(self):
|
||||
html = CONVERT(BREAKOUT_MD)
|
||||
self.assertNotIn(
|
||||
"' onmouseover='", html,
|
||||
f"URL quote escaped the href attribute and injected a handler: {html!r}",
|
||||
)
|
||||
|
||||
def test_transformed_anchor_exposes_no_event_handler_attribute(self):
|
||||
# Parsed, not regex-matched: after the fix the payload text still LIVES
|
||||
# inside the href value (harmlessly, as an odd URL), so a naive
|
||||
# /\son[a-z]+=/ scan would flag the fixed output too. What actually
|
||||
# matters is which ATTRIBUTES the anchor ends up carrying.
|
||||
html = CONVERT(BREAKOUT_MD)
|
||||
anchors = _parse_anchors(html)
|
||||
self.assertEqual(len(anchors), 1, f"expected one anchor, got {html!r}")
|
||||
names = set(anchors[0])
|
||||
self.assertEqual(
|
||||
names, {"href", "target", "rel"},
|
||||
f"unexpected attribute(s) on the anchor: {sorted(names)} in {html!r}",
|
||||
)
|
||||
|
||||
def test_legit_link_still_renders_a_usable_anchor(self):
|
||||
html = CONVERT("see [a/the docs](https://example.com/guide?a=1&b=2) please")
|
||||
self.assertIn("<a href='https://example.com/guide?a=1&b=2'", html)
|
||||
self.assertIn(">the docs</a>", html)
|
||||
self.assertIn("target='_blank'", html)
|
||||
|
||||
def test_link_text_is_escaped_once(self):
|
||||
for label in ["a <b> tag", "a <b> tag"]:
|
||||
with self.subTest(label=label):
|
||||
html = CONVERT(f"[a/{label}](https://example.com)")
|
||||
self.assertIn("<b>", html)
|
||||
self.assertNotIn("&lt;", html)
|
||||
|
||||
def test_prose_and_link_labels_are_safe_inside_formatted_notes(self):
|
||||
item = {"description": '<img src=x> [w/Read [a/**<Flux>**](https://example.com/?q=<Flux>)]\n<img src=y>'}
|
||||
load_markdown_functions(MANAGER_UTIL)["populate_markdown"](item)
|
||||
html = item["description"]
|
||||
self.assertEqual(_parse_element_names(html), ["p", "a", "b", "br"])
|
||||
self.assertIn("<B><Flux></B>", html)
|
||||
self.assertIn("<img src=x>", html)
|
||||
self.assertIn("<img src=y>", html)
|
||||
self.assertEqual(_parse_anchors(html)[0]["href"], "https://example.com/?q=<Flux>")
|
||||
|
||||
|
||||
class TestDescriptionUrlRoundTrip(unittest.TestCase):
|
||||
def test_description_urls_are_escaped_once(self):
|
||||
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
|
||||
cases = [
|
||||
("https://example.com/?q=<Flux>", "https://example.com/?q=<Flux>"),
|
||||
("https://example.com/?a=1&b=2", "https://example.com/?a=1&b=2"),
|
||||
("https://example.com/?q=<Flux>", "https://example.com/?q=<Flux>"),
|
||||
("https://example.com/?q=<Flux>", "https://example.com/?q=<Flux>"),
|
||||
("https://example.com/?a=1¬ebook=2©=3", "https://example.com/?a=1¬ebook=2©=3"),
|
||||
("https://example.com/?q=¬it;", "https://example.com/?q=¬it;"),
|
||||
("https://example.com/?q=&lt;", "https://example.com/?q=<"),
|
||||
("https://example.com/a**b**c?x=1&y=2", "https://example.com/a**b**c?x=1&y=2"),
|
||||
]
|
||||
for url, expected in cases:
|
||||
with self.subTest(url=url):
|
||||
item = {"description": f"[a/link]({url})"}
|
||||
populate(item)
|
||||
anchors = _parse_anchors(item["description"])
|
||||
self.assertEqual(anchors[0]["href"], expected)
|
||||
self.assertEqual(_parse_element_names(item["description"]), ["a"])
|
||||
|
||||
def test_decoded_entities_cannot_bypass_url_or_attribute_checks(self):
|
||||
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
|
||||
for url in ["javascript:alert`1`", "javascript:alert`1`", "java	script:alert`1`"]:
|
||||
with self.subTest(url=url):
|
||||
item = {"description": f"[a/link]({url})"}
|
||||
populate(item)
|
||||
self.assertEqual(_parse_anchors(item["description"])[0]["href"], "#")
|
||||
|
||||
url = "https://example.com/x' onmouseover='alert`1`"
|
||||
item = {"description": f"[a/link]({url})"}
|
||||
populate(item)
|
||||
anchor = _parse_anchors(item["description"])[0]
|
||||
self.assertEqual(anchor["href"], "https://example.com/x' onmouseover='alert`1`")
|
||||
self.assertEqual(set(anchor), {"href", "target", "rel"})
|
||||
|
||||
|
||||
class TestEscapedHrefSurvivesTheLaterPasses(unittest.TestCase):
|
||||
"""The escaper's output must still be intact by the time it is returned.
|
||||
|
||||
replace_a runs before the %%..%% / **..** / [w/..] / [i/..] passes, and those
|
||||
passes used to rewrite the INSIDE of the href it had just secured — putting
|
||||
<font>/<B>/<p> markup and fresh single quotes back into the attribute. That
|
||||
both corrupted legitimate URLs and undid the escaping.
|
||||
"""
|
||||
|
||||
def test_percent_markers_in_a_url_leave_the_href_intact(self):
|
||||
html = CONVERT("[a/t](https://e.com/%%A%%)")
|
||||
self.assertIn("href='https://e.com/%%A%%'", html)
|
||||
self.assertNotIn("<font", html)
|
||||
|
||||
def test_bold_markers_in_a_url_leave_the_href_intact(self):
|
||||
html = CONVERT("[a/t](https://e.com/a**b**c)")
|
||||
self.assertIn("href='https://e.com/a**b**c'", html)
|
||||
self.assertNotIn("<B>", html)
|
||||
|
||||
def test_note_markers_in_a_url_leave_the_href_intact(self):
|
||||
html = CONVERT("[a/t](https://e.com/[w/A])")
|
||||
self.assertIn("href='https://e.com/[w/A]'", html)
|
||||
self.assertNotIn("cm-warn-note", html)
|
||||
|
||||
def test_no_element_leaks_out_of_a_url(self):
|
||||
# Parsed rather than string-matched: the anchor must be the ONLY element.
|
||||
html = CONVERT("[a/t](https://e.com/%%A%%and**B**)")
|
||||
self.assertEqual(_parse_element_names(html), ["a"])
|
||||
|
||||
def test_nested_markdown_in_the_LINK_TEXT_still_renders(self):
|
||||
# Protecting the href must not cost the label. Moving the anchor
|
||||
# substitution last would have escaped these into visible mojibake.
|
||||
self.assertIn("<B>bold</B>", CONVERT("[a/**bold** text](https://e.com)"))
|
||||
self.assertIn("<font color='white'>hi</font>", CONVERT("[a/%%hi%% there](https://e.com)"))
|
||||
|
||||
def test_markup_outside_a_link_is_unaffected(self):
|
||||
html = CONVERT("%%white%% and **bold** and [w/warn]")
|
||||
self.assertIn("<font color='white'>white</font>", html)
|
||||
self.assertIn("<B>bold</B>", html)
|
||||
self.assertIn("cm-warn-note", html)
|
||||
|
||||
def test_the_newline_pass_cannot_reach_inside_the_href(self):
|
||||
# The newline substitution is the LAST thing convert_markdown_to_html
|
||||
# does, so restoring the href before it let '<BR>' be injected into the
|
||||
# attribute the escaper had secured. Not exploitable — '<BR>' carries no
|
||||
# quote — but it is the same class of breach this function exists to fix,
|
||||
# so the invariant is pinned all the way to the returned string.
|
||||
html = CONVERT("[a/t](https://e.com/a\nb)")
|
||||
anchors = _parse_anchors(html)
|
||||
self.assertEqual(len(anchors), 1)
|
||||
self.assertNotIn("<BR>", anchors[0]["href"])
|
||||
self.assertEqual(_parse_element_names(html), ["a"])
|
||||
|
||||
def test_newlines_outside_a_link_still_become_line_breaks(self):
|
||||
html = CONVERT("first\nsecond")
|
||||
self.assertIn("<BR>", html)
|
||||
|
||||
def test_a_forged_placeholder_in_the_input_cannot_hijack_restoration(self):
|
||||
html = CONVERT("\x00H0\x00 [a/t](https://e.com/real)")
|
||||
self.assertIn("href='https://e.com/real'", html)
|
||||
self.assertEqual(html.count("https://e.com/real"), 1)
|
||||
|
||||
|
||||
class TestUrlSchemeAllowlist(unittest.TestCase):
|
||||
def test_helpers_are_public_on_manager_util(self):
|
||||
self.assertTrue(callable(getattr(MANAGER_UTIL, "escape_html_attribute", None)))
|
||||
self.assertTrue(callable(getattr(MANAGER_UTIL, "sanitize_url", None)))
|
||||
|
||||
def test_dangerous_schemes_are_rejected(self):
|
||||
for bad in [
|
||||
"javascript:alert`1`",
|
||||
"JaVaScRiPt:alert`1`",
|
||||
" javascript:alert`1`",
|
||||
"java\tscript:alert`1`",
|
||||
"java\nscript:alert`1`",
|
||||
"\x01javascript:alert`1`",
|
||||
"data:text/html;base64,PHNjcmlwdD4=",
|
||||
"vbscript:msgbox",
|
||||
]:
|
||||
with self.subTest(bad=bad):
|
||||
self.assertEqual(MANAGER_UTIL.sanitize_url(bad), "#")
|
||||
|
||||
def test_safe_urls_pass_through(self):
|
||||
for good in [
|
||||
"https://example.com/a?b=1",
|
||||
"http://example.com",
|
||||
"HTTPS://Example.COM/Path",
|
||||
"/relative/path",
|
||||
"relative/path",
|
||||
"#in-page-anchor",
|
||||
"//protocol-relative/path",
|
||||
# A relative link may legitimately contain '&'. An earlier version
|
||||
# rejected this shape while guarding against an entity-hidden colon;
|
||||
# the guarantee comes from the escape layer instead (below), so the
|
||||
# rejection was redundant and only broke real links.
|
||||
"a&b/c",
|
||||
"docs/page?x=1&y=2",
|
||||
]:
|
||||
with self.subTest(good=good):
|
||||
self.assertEqual(MANAGER_UTIL.sanitize_url(good), good)
|
||||
|
||||
def test_entity_hidden_colon_is_inert_after_escaping(self):
|
||||
# Asserted on the POST-ESCAPE href, because that is where the safety
|
||||
# actually comes from: sanitize_url returns these unchanged (they have no
|
||||
# real colon, so they read as relative), and escape_html_attribute then
|
||||
# escapes the '&'. Entity decoding in an attribute is single-pass, so
|
||||
# what the browser ends up with is a literal string, never a scheme.
|
||||
for hidden in ["javascript:alert`1`", "javascript:alert`1`",
|
||||
"javascript:alert`1`"]:
|
||||
with self.subTest(hidden=hidden):
|
||||
href = MANAGER_UTIL.escape_html_attribute(MANAGER_UTIL.sanitize_url(hidden))
|
||||
self.assertIn("&", href)
|
||||
self.assertNotIn(":", href)
|
||||
self.assertNotIn("j", href)
|
||||
self.assertNotIn("j", href)
|
||||
|
||||
def test_entity_hidden_colon_in_markdown_produces_no_live_scheme(self):
|
||||
html = CONVERT("[a/click](javascript:alert`1`)")
|
||||
anchors = _parse_anchors(html)
|
||||
self.assertEqual(len(anchors), 1)
|
||||
# The parser decodes entities exactly as a browser would; after that the
|
||||
# href must NOT be a javascript: URL.
|
||||
self.assertFalse(anchors[0]["href"].lower().startswith("javascript:"))
|
||||
|
||||
def test_dangerous_scheme_in_markdown_does_not_reach_the_href(self):
|
||||
html = CONVERT("[a/click](javascript:alert`1`)")
|
||||
self.assertNotIn("javascript:", html.lower())
|
||||
self.assertIn("href='#'", html)
|
||||
|
||||
|
||||
class TestAttributeEscaper(unittest.TestCase):
|
||||
def test_escapes_all_five_characters(self):
|
||||
self.assertEqual(
|
||||
MANAGER_UTIL.escape_html_attribute("""&<>"'"""),
|
||||
"&<>"'",
|
||||
)
|
||||
|
||||
def test_ampersand_is_escaped_first_and_only_once(self):
|
||||
self.assertEqual(MANAGER_UTIL.escape_html_attribute("a&b"), "a&b")
|
||||
|
||||
def test_non_string_input_is_coerced(self):
|
||||
self.assertEqual(MANAGER_UTIL.escape_html_attribute(None), "None")
|
||||
|
||||
|
||||
class TestServerTitleTransformIsTheOnlyUpstreamControl(unittest.TestCase):
|
||||
"""Pin the server-side facts that the bare flyover title sinks rest on.
|
||||
|
||||
js/custom-nodes-manager.js says, at both flyover pack-title sinks, that the
|
||||
title is already escaped by populate_markdown and so must NOT be escaped
|
||||
again at the client (a second escape would double-escape a legitimate
|
||||
<>-title into mojibake). That comment is a load-bearing claim about THIS
|
||||
file's subject: it is the whole reason those two sinks are bare. These
|
||||
cases make the claim machine-checked, so if the server side ever stops
|
||||
covering `title` the suite says so, instead of leaving a comment asserting
|
||||
a protection that is gone.
|
||||
"""
|
||||
|
||||
def test_populate_markdown_escapes_the_title_before_it_reaches_the_client(self):
|
||||
# The specific link the sink comments name. This is the case that fails
|
||||
# if someone drops `title` from populate_markdown, which is the drift
|
||||
# the bare sinks are actually exposed to.
|
||||
populate_markdown = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
|
||||
pack = {"title": "Nodes for <Flux> workflows"}
|
||||
populate_markdown(pack)
|
||||
self.assertEqual(pack["title"], "Nodes for <Flux> workflows")
|
||||
|
||||
def test_sanitize_tag_escapes_angle_brackets(self):
|
||||
self.assertEqual(MANAGER_UTIL.sanitize_tag("<img>"), "<img>")
|
||||
|
||||
def test_sanitize_tag_does_not_escape_quotes_or_ampersand(self):
|
||||
# Documented limit, not a defect: both title interpolations are in
|
||||
# element-text position, where quotes are inert. It IS the reason a
|
||||
# title moving into attribute position would need escaping.
|
||||
self.assertEqual(MANAGER_UTIL.sanitize_tag("\"a\" & 'b'"), "\"a\" & 'b'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,487 @@
|
||||
"""Inventory the current direct message-sink calls and reviewed error builders.
|
||||
|
||||
These sinks accept HTML because some callers render buttons and server-escaped
|
||||
metadata. Unknown arguments or assignments require a provenance review. Runtime
|
||||
rendering tests separately verify that raw text stays inert and escaped titles
|
||||
remain readable; this source inventory is not a general JavaScript dataflow check.
|
||||
"""
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from js_lift import _skip_literal
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
JS_DIR = REPO_ROOT / "js"
|
||||
|
||||
SINK_FILES = [
|
||||
"common.js",
|
||||
"custom-nodes-manager.js",
|
||||
"model-manager.js",
|
||||
"node-usage-analyzer.js",
|
||||
]
|
||||
|
||||
SINK_METHODS = r"showStatus|showMessage|showError|showSelection"
|
||||
|
||||
# Capture receivers so a new alias is reviewed instead of silently omitted.
|
||||
CALL_RE = re.compile(
|
||||
r"(?<![\w$.])([\w$]+(?:\??\.[\w$]+)*)"
|
||||
rf"\??\.({SINK_METHODS})\("
|
||||
)
|
||||
SINK_CALL_RE = re.compile(
|
||||
rf"(?:\.(?:{SINK_METHODS})|\[\s*['\"](?:{SINK_METHODS})['\"]\s*\])"
|
||||
r"\s*(?:\?\.\s*)?\("
|
||||
)
|
||||
|
||||
PINNED_RECEIVERS = frozenset({"this", "self", "this.ui"})
|
||||
|
||||
# Destructuring would hide calls from the receiver-based inventory.
|
||||
DESTRUCTURE_RE = re.compile(
|
||||
r"(?:const|let|var)\s*\{[^}]*\b"
|
||||
rf"(?:{SINK_METHODS})"
|
||||
r"\b[^}]*\}"
|
||||
)
|
||||
|
||||
SERVER_ESCAPED = "server-escaped" # already display-ready; caller must NOT escape
|
||||
RAW = "raw" # not display-ready; caller MUST escape
|
||||
NO_DATA = "no-data" # no channel/registry value interpolated
|
||||
FORWARDED = "forwarded" # showError's own delegation; see HtmlByDesignSinkTest
|
||||
|
||||
|
||||
def _argument(source: str, start: int) -> str:
|
||||
"""Read a complete argument, including multiline templates."""
|
||||
depth = 1
|
||||
end = start
|
||||
while depth:
|
||||
skipped = _skip_literal(source, end)
|
||||
if skipped != end:
|
||||
end = skipped
|
||||
continue
|
||||
if source[end] == "(":
|
||||
depth += 1
|
||||
elif source[end] == ")":
|
||||
depth -= 1
|
||||
end += 1
|
||||
if source[end:end + 1] == ";":
|
||||
end += 1
|
||||
return re.sub(r"\s+", " ", source[start:end].strip())
|
||||
|
||||
|
||||
def _call_sites():
|
||||
"""Return (file, line, receiver, method, argument) for direct calls."""
|
||||
sites = []
|
||||
for name in SINK_FILES:
|
||||
source = (JS_DIR / name).read_text(encoding="utf-8")
|
||||
matches = list(CALL_RE.finditer(source))
|
||||
parsed = {match.end() for match in matches}
|
||||
for candidate in SINK_CALL_RE.finditer(source):
|
||||
if candidate.end() not in parsed:
|
||||
lineno = source.count("\n", 0, candidate.start()) + 1
|
||||
raise AssertionError(f"Unsupported message-sink call at {name}:{lineno}")
|
||||
for match in matches:
|
||||
lineno = source.count("\n", 0, match.start()) + 1
|
||||
sites.append((name, lineno, match.group(1), match.group(2),
|
||||
_argument(source, match.end())))
|
||||
return sites
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE REGISTRY. Keyed by (file, method, argument-source). Every showStatus and
|
||||
# showMessage call site must appear here with a provenance verdict and a reason.
|
||||
# ---------------------------------------------------------------------------
|
||||
REGISTRY = {
|
||||
# --- custom-nodes-manager.js -------------------------------------------
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`${prevViewRowsLength.toLocaleString()} custom nodes`);"):
|
||||
(NO_DATA, "a formatted row count"),
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`Loading node mappings (${mode}) ...`);"):
|
||||
(NO_DATA, "`mode` is a datasrc-combo value chosen in the UI, not channel data"),
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`${label} ${item.title} ...`);"):
|
||||
(SERVER_ESCAPED,
|
||||
"item.title comes from /customnode/getlist, where populate_markdown -> "
|
||||
"sanitize_tag has already escaped it; escaping again shows entities"),
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`${label} ${Object.keys(result).length} custom node(s) successfully`);"):
|
||||
(NO_DATA, "a count of queue results"),
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`Loading missing nodes (${mode}) ...`);"):
|
||||
(NO_DATA, "UI-chosen mode"),
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`Loading alternatives (${mode}) ...`);"):
|
||||
(NO_DATA, "UI-chosen mode"),
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`Loading workflow usage analysis ...`);"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`Loading custom nodes (${mode}) ...`);"):
|
||||
(NO_DATA, "UI-chosen mode"),
|
||||
("custom-nodes-manager.js", "showMessage",
|
||||
"`To apply the installed/updated/disabled/enabled custom node, please restart "
|
||||
"ComfyUI. And refresh browser.`, \"red\");"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("custom-nodes-manager.js", "showMessage", "\"\");"):
|
||||
(NO_DATA, "clears the banner"),
|
||||
|
||||
# --- model-manager.js ---------------------------------------------------
|
||||
("model-manager.js", "showStatus",
|
||||
"`${grid.viewRows.length.toLocaleString()} external models`);"):
|
||||
(NO_DATA, "a formatted row count"),
|
||||
("model-manager.js", "showStatus", "`Install ${item.name} ...`);"):
|
||||
(SERVER_ESCAPED,
|
||||
"item.name comes from /externalmodel/getlist, where populate_markdown -> "
|
||||
"sanitize_tag has already escaped it. WI-112 added a caller escape here "
|
||||
"and it rendered '<Flux>' to the user; removing it is the fix"),
|
||||
("model-manager.js", "showStatus", "`Install ${result.length} models successfully`);"):
|
||||
(NO_DATA, "a count of queue results"),
|
||||
("model-manager.js", "showStatus", "`Loading external model list ...`);"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("model-manager.js", "showMessage",
|
||||
"`To apply the installed model, please click the 'Refresh' button.`, \"red\")"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("model-manager.js", "showMessage", "\"\");"):
|
||||
(NO_DATA, "clears the banner"),
|
||||
|
||||
# --- node-usage-analyzer.js --------------------------------------------
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`${grid.viewRows.length.toLocaleString()} installed packages`);"):
|
||||
(NO_DATA, "a formatted row count"),
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`Install ${sanitizeHTML(String(item.name))} ...`);"):
|
||||
(RAW,
|
||||
"item.name is packKey (loadData sets `name: packKey`) — a raw dict key no "
|
||||
"server transform touches, so this caller escapes it. The ONLY raw-provenance "
|
||||
"showStatus caller in the four files"),
|
||||
("node-usage-analyzer.js", "showStatus", "msg);"):
|
||||
(SERVER_ESCAPED,
|
||||
"uninstallNodes preserves the escaped title or escapes the raw name fallback; "
|
||||
"test_node_usage_escaping executes that path through the panel sink"),
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`Uninstalled ${targets.length} custom node(s) successfully`);"):
|
||||
(NO_DATA, "a count of targets"),
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`Install ${Object.keys(result).length} models successfully`);"):
|
||||
(NO_DATA, "a count of queue results"),
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`Uninstall ${Object.keys(result).length} custom node(s) successfully`);"):
|
||||
(NO_DATA, "a count of queue results"),
|
||||
("node-usage-analyzer.js", "showStatus", "`Analyzing node usage ...`);"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("node-usage-analyzer.js", "showMessage",
|
||||
"`To apply the uninstalled custom nodes, please restart ComfyUI and refresh "
|
||||
"browser.`, \"red\");"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("node-usage-analyzer.js", "showMessage",
|
||||
"`To apply the installed model, please click the 'Refresh' button.`, \"red\");"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("node-usage-analyzer.js", "showMessage", "\"No workflows were found for analysis.\");"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("node-usage-analyzer.js", "showMessage", "\"\");"):
|
||||
(NO_DATA, "clears the banner"),
|
||||
|
||||
# --- showError's own delegation, in each of the three classes -----------
|
||||
# Not an application caller: this IS `showError(err) { this.showMessage(err,
|
||||
# "red"); }`. Whether `err` is display-ready is the showError CALLER's
|
||||
# responsibility, which HtmlByDesignSinkTest below enforces separately. It is
|
||||
# registered rather than pattern-excluded so that if showError ever stops
|
||||
# forwarding — or starts forwarding something else — this entry goes stale
|
||||
# and the guard says so.
|
||||
("custom-nodes-manager.js", "showMessage", "err, \"red\");"):
|
||||
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
|
||||
("model-manager.js", "showMessage", "err, \"red\");"):
|
||||
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
|
||||
("node-usage-analyzer.js", "showMessage", "err, \"red\");"):
|
||||
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
|
||||
}
|
||||
|
||||
DISPLAY_READY_METHODS = ("showStatus", "showMessage")
|
||||
|
||||
# A caller escape is spelled this way throughout the UI files.
|
||||
ESCAPE_MARKER = "sanitizeHTML"
|
||||
|
||||
|
||||
class MessageSinkRegistryTest(unittest.TestCase):
|
||||
"""The registry must account for every display-ready-sink call site."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.sites = _call_sites()
|
||||
|
||||
def test_the_four_sink_implementations_all_exist(self):
|
||||
# If a file stops defining its own sink (e.g. it starts delegating to
|
||||
# createUIStateManager) the registry's routing assumptions change and
|
||||
# this guard must be re-derived rather than silently kept.
|
||||
own = {
|
||||
"custom-nodes-manager.js": ".cn-manager-status",
|
||||
"model-manager.js": ".cmm-manager-status",
|
||||
"node-usage-analyzer.js": ".nu-manager-status",
|
||||
}
|
||||
for name, selector in own.items():
|
||||
source = (JS_DIR / name).read_text(encoding="utf-8")
|
||||
self.assertIn("showStatus(msg, color)", source, "%s lost its own sink" % name)
|
||||
self.assertIn(selector, source)
|
||||
common = (JS_DIR / "common.js").read_text(encoding="utf-8")
|
||||
self.assertIn("export function createUIStateManager(", common)
|
||||
self.assertIn("showStatus: (msg, color)", common)
|
||||
|
||||
def test_every_caller_is_classified(self):
|
||||
"""FAIL-CLOSED: an unregistered call site breaks the build."""
|
||||
observed = Counter((name, method, arg) for name, _, _, method, arg in self.sites
|
||||
if method in DISPLAY_READY_METHODS)
|
||||
duplicates = {
|
||||
('custom-nodes-manager.js', 'showStatus', '`Loading workflow usage analysis ...`);'),
|
||||
('node-usage-analyzer.js', 'showMessage',
|
||||
'`To apply the uninstalled custom nodes, please restart ComfyUI and refresh browser.`, "red");'),
|
||||
}
|
||||
expected = Counter({key: 1 + (key in duplicates) for key in REGISTRY})
|
||||
self.assertEqual(observed, expected, 'Changed message calls require a provenance review')
|
||||
|
||||
def test_the_observed_receivers_are_the_pinned_set(self):
|
||||
observed = {receiver for _name, _lineno, receiver, _m, _a in self.sites}
|
||||
self.assertEqual(
|
||||
observed, set(PINNED_RECEIVERS),
|
||||
"the set of receivers calling the message sinks changed: %s. These "
|
||||
"sinks assign innerHTML and DO NOT escape, so every route to them "
|
||||
"must be accounted for. Classify the new receiver's call sites in "
|
||||
"REGISTRY and add it to PINNED_RECEIVERS — do not narrow CALL_RE to "
|
||||
"make this pass." % (sorted(observed ^ set(PINNED_RECEIVERS)),),
|
||||
)
|
||||
|
||||
def test_no_sink_method_is_destructured_out_of_its_receiver(self):
|
||||
for name in SINK_FILES:
|
||||
source = (JS_DIR / name).read_text(encoding="utf-8")
|
||||
with self.subTest(file=name):
|
||||
self.assertEqual(
|
||||
DESTRUCTURE_RE.findall(source), [],
|
||||
"%s destructures a message-sink method out of its receiver. "
|
||||
"The resulting bare calls are invisible to this guard; call "
|
||||
"them through their receiver instead." % (name,),
|
||||
)
|
||||
|
||||
def test_unsupported_sink_calls_require_review(self):
|
||||
source = (JS_DIR / "common.js").read_text(encoding="utf-8")
|
||||
for call in ('this["showMessage"](raw);',
|
||||
'createUIStateManager(element).showError(raw);'):
|
||||
with self.subTest(call=call), patch(__name__ + ".SINK_FILES", ["common.js"]):
|
||||
with patch.object(Path, "read_text", return_value=source + "\n" + call):
|
||||
with self.assertRaisesRegex(AssertionError, "Unsupported message-sink call"):
|
||||
_call_sites()
|
||||
|
||||
def test_the_registry_has_no_stale_entries(self):
|
||||
"""A registry entry whose call site is gone or whose argument changed."""
|
||||
live = {
|
||||
(name, method, arg)
|
||||
for name, _lineno, _receiver, method, arg in self.sites
|
||||
if method in DISPLAY_READY_METHODS
|
||||
}
|
||||
stale = sorted(key for key in REGISTRY if key not in live)
|
||||
self.assertEqual(
|
||||
stale, [],
|
||||
"REGISTRY entries no longer match any call site — the caller was removed "
|
||||
"or its argument changed, so its provenance verdict is unverified:\n %s"
|
||||
% "\n ".join("%s %s(%s" % key for key in stale),
|
||||
)
|
||||
|
||||
def test_server_escaped_callers_do_not_escape_again(self):
|
||||
for (name, method, arg), (provenance, reason) in REGISTRY.items():
|
||||
if provenance != SERVER_ESCAPED:
|
||||
continue
|
||||
with self.subTest(file=name, method=method, arg=arg):
|
||||
self.assertNotIn(
|
||||
ESCAPE_MARKER, arg,
|
||||
"%s %s escapes an ALREADY-escaped value — the user reads "
|
||||
"'<Flux>' instead of '<Flux>'. Reason on file: %s"
|
||||
% (name, method, reason),
|
||||
)
|
||||
|
||||
def test_raw_callers_escape(self):
|
||||
for (name, method, arg), (provenance, reason) in REGISTRY.items():
|
||||
if provenance != RAW:
|
||||
continue
|
||||
with self.subTest(file=name, method=method, arg=arg):
|
||||
self.assertIn(
|
||||
ESCAPE_MARKER, arg,
|
||||
"%s %s passes a RAW value into a sink that does not escape. "
|
||||
"Reason on file: %s" % (name, method, reason),
|
||||
)
|
||||
|
||||
def test_no_data_callers_interpolate_no_channel_value(self):
|
||||
# Interpolations are allowed, but only of things that cannot carry
|
||||
# channel/registry text: counts, lengths, and the UI-chosen mode.
|
||||
allowed = re.compile(
|
||||
r"^\$\{(?:[A-Za-z_.]*\.length|Object\.keys\([A-Za-z_.]+\)\.length"
|
||||
r"|[A-Za-z_.]+\.toLocaleString\(\)|mode|label"
|
||||
r"|[A-Za-z_.]*\.length\.toLocaleString\(\))\}$"
|
||||
)
|
||||
for (name, method, arg), (provenance, reason) in REGISTRY.items():
|
||||
if provenance != NO_DATA:
|
||||
continue
|
||||
for expr in re.findall(r"\$\{[^}]*\}", arg):
|
||||
with self.subTest(file=name, method=method, expr=expr):
|
||||
self.assertRegex(
|
||||
expr, allowed,
|
||||
"%s %s is classified %s but interpolates %s, which is not a "
|
||||
"count or a UI-chosen value. Re-classify it. Reason on file: %s"
|
||||
% (name, method, NO_DATA, expr, reason),
|
||||
)
|
||||
|
||||
|
||||
class HtmlByDesignSinkTest(unittest.TestCase):
|
||||
"""showSelection / showError render real markup and are NOT display-ready sinks."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.sites = _call_sites()
|
||||
|
||||
def test_show_selection_receives_no_channel_data(self):
|
||||
expected = {
|
||||
"custom-nodes-manager.js": [
|
||||
'"");',
|
||||
'"");',
|
||||
# Unknown labels in this builder have behavioral coverage.
|
||||
'list.join(""));',
|
||||
],
|
||||
"model-manager.js": [
|
||||
'"");',
|
||||
'"");',
|
||||
'`<span>Selected <b>${selectedList.length}</b> models <button '
|
||||
'class="cmm-btn-install p-button p-component" mode="install">Install</button>`);',
|
||||
],
|
||||
"node-usage-analyzer.js": [
|
||||
'"");',
|
||||
'"");',
|
||||
'`<span>Selected <b>${selectedList.length}</b> packages (none can be uninstalled)</span>`);',
|
||||
'` <div class="nu-selected-buttons"> <span>Selected <b>${installedSelected.length}</b> '
|
||||
'installed packages</span> <button class="nu-btn-uninstall" mode="uninstall">'
|
||||
'Uninstall Selected</button> </div> `);',
|
||||
],
|
||||
}
|
||||
actual = Counter((name, arg) for name, _, _, method, arg in self.sites if method == "showSelection")
|
||||
self.assertEqual(actual, Counter((name, arg) for name, args in expected.items() for arg in args))
|
||||
|
||||
def test_show_error_arguments_are_escaped_or_literal(self):
|
||||
# showError forwards to showMessage, which does not escape, AND its
|
||||
# errorMsg is separately handed to ComfyUI-core show_message(). So every
|
||||
# non-literal argument must already be escaped at the call site.
|
||||
escaped = {
|
||||
"custom-nodes-manager.js": {
|
||||
'`Failed to get custom node mappings: ${sanitizeHTML(String(res.error))}`);',
|
||||
'`Failed to get alternatives: ${sanitizeHTML(String(res.error))}`);',
|
||||
'`Failed to get workflow data: ${sanitizeHTML(String(result.error))}`);',
|
||||
},
|
||||
"node-usage-analyzer.js": {'sanitizeHTML(String(result.error)));'},
|
||||
}
|
||||
for name, lineno, _receiver, method, arg in self.sites:
|
||||
if method != "showError":
|
||||
continue
|
||||
is_literal = re.fullmatch(
|
||||
r'''(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*\);''', arg,
|
||||
) is not None
|
||||
# These direct arguments are checked by the per-file guards below.
|
||||
is_error_var = re.fullmatch(r"errorMsg\s*\);", arg) is not None
|
||||
with self.subTest(file=name, line=lineno, arg=arg):
|
||||
if is_literal or is_error_var:
|
||||
continue
|
||||
self.assertIn(
|
||||
arg, escaped.get(name, set()),
|
||||
"%s:%d passes an unescaped non-literal into showError, which does "
|
||||
"not escape and whose text also reaches core show_message()"
|
||||
% (name, lineno),
|
||||
)
|
||||
|
||||
def test_every_error_message_builder_escapes_its_server_text(self):
|
||||
common = {
|
||||
'errorMsg = "";',
|
||||
"errorMsg += `This action is not allowed with this security level configuration.\\n`;",
|
||||
"errorMsg += sanitizeHTML(await res.text()) + '\\n';",
|
||||
}
|
||||
queue_error = "errorMsg += sanitizeHTML(String(v)) + '\\n';"
|
||||
outdated = "errorMsg += `ComfyUI version is outdated. Please update ComfyUI to use Manager normally.\\n`;"
|
||||
expected = {
|
||||
"custom-nodes-manager.js": common | {
|
||||
"errorMsg = `'${item.title}': `;",
|
||||
"errorMsg = `Not found custom node: ${hash}`;",
|
||||
queue_error, outdated,
|
||||
},
|
||||
"model-manager.js": common | {
|
||||
"errorMsg = `'${item.name}': `;", queue_error, outdated,
|
||||
},
|
||||
"node-usage-analyzer.js": common | {
|
||||
"errorMsg = `'${sanitizeHTML(String(item.name))}': `;", queue_error,
|
||||
},
|
||||
"common.js": common | {"errorMsg = `'${displayTitle}': `;"},
|
||||
}
|
||||
mutations = re.compile(r"(?<![\w$.])errorMsg\s*(?:[+*/%&|^?-]*=(?!=)|\+\+|--)[^;]*;")
|
||||
for name, statements in expected.items():
|
||||
source = (JS_DIR / name).read_text(encoding="utf-8")
|
||||
actual = {re.sub(r"\s+", " ", match) for match in mutations.findall(source)}
|
||||
with self.subTest(file=name):
|
||||
self.assertEqual(actual, statements)
|
||||
|
||||
def test_error_guard_rejects_unclassified_arguments(self):
|
||||
for argument in (
|
||||
"err);", "errText);", "errorMsg$raw);", "err + extra);", "err.detail);",
|
||||
'"prefix " + errText);', "'prefix ' + errText);",
|
||||
"sanitizeHTML(err) + extra);",
|
||||
):
|
||||
with self.subTest(argument=argument):
|
||||
guard = HtmlByDesignSinkTest("test_show_error_arguments_are_escaped_or_literal")
|
||||
guard.sites = [("custom-nodes-manager.js", 1, "this", "showError", argument)]
|
||||
result = unittest.TestResult()
|
||||
guard.run(result)
|
||||
self.assertEqual(len(result.failures), 1)
|
||||
self.assertEqual(result.errors, [])
|
||||
|
||||
def test_html_guards_reject_unreviewed_source(self):
|
||||
cases = [
|
||||
("custom-nodes-manager.js", 'this.showSelection(list.join(""));',
|
||||
'this.showSelection(selectedList[0].title);',
|
||||
"test_show_selection_receives_no_channel_data"),
|
||||
("node-usage-analyzer.js", "${installedSelected.length}</b>",
|
||||
"${installedSelected[0].title}</b>",
|
||||
"test_show_selection_receives_no_channel_data"),
|
||||
("custom-nodes-manager.js", 'let errorMsg = "";',
|
||||
'let errorMsg = "";\nerrorMsg += response.message;',
|
||||
"test_every_error_message_builder_escapes_its_server_text"),
|
||||
("model-manager.js", "errorMsg += sanitizeHTML(String(v)) + '\\n';",
|
||||
"errorMsg += sanitizeHTML(String(v)) + response.message + '\\n';",
|
||||
"test_every_error_message_builder_escapes_its_server_text"),
|
||||
]
|
||||
for name, before, after, test in cases:
|
||||
with self.subTest(file=name, replacement=after), tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
for filename in SINK_FILES:
|
||||
source = (JS_DIR / filename).read_text(encoding="utf-8")
|
||||
if filename == name:
|
||||
self.assertIn(before, source)
|
||||
source = source.replace(before, after, 1)
|
||||
(directory / filename).write_text(source, encoding="utf-8")
|
||||
with patch(__name__ + ".JS_DIR", directory):
|
||||
guard = HtmlByDesignSinkTest(test)
|
||||
guard.sites = _call_sites()
|
||||
result = unittest.TestResult()
|
||||
guard.run(result)
|
||||
self.assertTrue(result.failures)
|
||||
self.assertEqual(result.errors, [])
|
||||
|
||||
def test_duplicate_calls_require_review(self):
|
||||
for cls, test, argument in (
|
||||
(MessageSinkRegistryTest, 'test_every_caller_is_classified', 'msg);'),
|
||||
(HtmlByDesignSinkTest, 'test_show_selection_receives_no_channel_data', 'list.join(""));'),
|
||||
):
|
||||
with self.subTest(test=test):
|
||||
guard = cls(test)
|
||||
guard.sites = _call_sites()
|
||||
guard.sites.append(next(site for site in guard.sites if site[-1] == argument))
|
||||
result = unittest.TestResult()
|
||||
guard.run(result)
|
||||
self.assertTrue(result.failures)
|
||||
self.assertEqual(result.errors, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,412 @@
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from js_lift import NODE, JsSource, parse as _parse, run_node as _run_node, slice_braced
|
||||
from manager_test_utils import load_manager_util, load_markdown_functions
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
JS_DIR = Path(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
|
||||
JS = JsSource(JS_DIR)
|
||||
MANAGER_UTIL = load_manager_util()
|
||||
|
||||
|
||||
MODEL_MANAGER_SRC = (JS_DIR / "model-manager.js").read_text(encoding="utf-8")
|
||||
COMMON_SRC = (JS_DIR / "common.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _lift_sanitize_html() -> str:
|
||||
return JS.lift_declaration("common.js", "export function sanitizeHTML(")
|
||||
|
||||
|
||||
def _lift_icons() -> str:
|
||||
"""The real `icons` constant — the url formatter embeds icons.download."""
|
||||
return JS.lift_declaration("common.js", "export const icons = ")
|
||||
|
||||
|
||||
def _lift_url_helpers() -> str:
|
||||
"""sanitizeUrl + safeHref, which live in common.js since the consolidation.
|
||||
|
||||
Returns '' on a pre-consolidation tree so the MANAGER_JS_DIR reproduce still
|
||||
works against an older revision: there the helpers were local to
|
||||
model-manager.js and _lift_model_manager_helpers picks them up instead.
|
||||
"""
|
||||
if "export function sanitizeUrl" not in COMMON_SRC:
|
||||
return ""
|
||||
span = JS.lift_span("common.js", "export const SAFE_URL_SCHEMES",
|
||||
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
|
||||
return span.replace("export ", "")
|
||||
|
||||
|
||||
def _lift_model_manager_helpers() -> str:
|
||||
"""The escaping helpers that are still local to model-manager.js.
|
||||
|
||||
escapeCell and renderOptions stay in the client file — they are specific to
|
||||
its grid and its filter dropdowns. On a pre-consolidation tree this block
|
||||
also still contains sanitizeUrl/safeHref, which is why the slice starts at
|
||||
whichever marker that revision actually has.
|
||||
"""
|
||||
if "const escapeCell" not in MODEL_MANAGER_SRC:
|
||||
return ""
|
||||
marker = ("const SAFE_URL_SCHEMES" if "const SAFE_URL_SCHEMES" in MODEL_MANAGER_SRC
|
||||
else "const escapeCell")
|
||||
start = MODEL_MANAGER_SRC.index(marker)
|
||||
end = MODEL_MANAGER_SRC.index("const renderOptions")
|
||||
end = MODEL_MANAGER_SRC.index('}).join("");', end) + len('}).join("");')
|
||||
return MODEL_MANAGER_SRC[start:end]
|
||||
|
||||
|
||||
def _slice_column(column_id: str) -> str:
|
||||
"""Return one column-definition object out of the `}, {`-chained columns array.
|
||||
|
||||
These objects are literals inside an array, so the first `{` after the id
|
||||
marker belongs to the NEXT column — brace matching cannot be used. The
|
||||
object instead ends at the first `}` sitting at the array's own indent.
|
||||
"""
|
||||
start = MODEL_MANAGER_SRC.index("id: %s," % column_id)
|
||||
end = MODEL_MANAGER_SRC.index("\n\t\t}", start)
|
||||
return MODEL_MANAGER_SRC[start:end]
|
||||
|
||||
|
||||
def _lift_formatter(anchor: str) -> str:
|
||||
"""Lift the ``formatter: ...`` at ``anchor`` as a bare function expression."""
|
||||
block = slice_braced(MODEL_MANAGER_SRC, anchor)
|
||||
marker = "formatter:"
|
||||
return block[block.index(marker) + len(marker):].strip()
|
||||
|
||||
|
||||
NAME_FORMATTER_ANCHOR = "formatter: function(name, rowItem, columnItem, cellNode)"
|
||||
URL_FORMATTER_ANCHOR = "formatter: (url, rowItem, columnItem)"
|
||||
|
||||
BREAKOUT_HREF = "https://evil.example/a onmouseover=alert(1) x"
|
||||
QUOTE_BREAKOUT = 'https://evil.example/a"><img src=x onerror=alert(1)>'
|
||||
TEXT_PAYLOAD = "<img src=x onerror=alert(1)>"
|
||||
ATTR_BREAKOUT = '" onfocus=alert(1) x="'
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class ModelManagerSinkEscapingTest(unittest.TestCase):
|
||||
"""Every sink is exercised through the REAL lifted code, never a copy."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.preamble = "\n".join([
|
||||
_lift_sanitize_html(),
|
||||
_lift_icons(),
|
||||
_lift_url_helpers(),
|
||||
_lift_model_manager_helpers(),
|
||||
])
|
||||
|
||||
def _eval(self, expressions: dict) -> dict:
|
||||
body = ",".join("%s: %s" % (json.dumps(k), v) for k, v in expressions.items())
|
||||
return _run_node(
|
||||
"%s\n"
|
||||
"const nameFormatter = %s;\n"
|
||||
"const urlFormatter = %s;\n"
|
||||
"console.log(JSON.stringify({%s}));\n"
|
||||
% (self.preamble,
|
||||
_lift_formatter(NAME_FORMATTER_ANCHOR),
|
||||
_lift_formatter(URL_FORMATTER_ANCHOR),
|
||||
body)
|
||||
)
|
||||
|
||||
def _assert_inert(self, markup: str, allowed_tags):
|
||||
"""No element outside the allow-list, and no event-handler attribute."""
|
||||
parsed = _parse(markup)
|
||||
self.assertEqual(
|
||||
[t for t in parsed.tags if t not in allowed_tags], [],
|
||||
"live element injected into: %r" % (markup,),
|
||||
)
|
||||
handlers = [name for name, _ in parsed.attrs if name.lower().startswith("on")]
|
||||
self.assertEqual(handlers, [], "event-handler attribute injected into: %r" % (markup,))
|
||||
|
||||
# --- D-IV: name column, previously UNQUOTED href ------------------------
|
||||
def test_d4_name_href_survives_breakout_attempts(self):
|
||||
out = self._eval({
|
||||
"space": "nameFormatter('Model', {reference: %s})" % json.dumps(BREAKOUT_HREF),
|
||||
"quote": "nameFormatter('Model', {reference: %s})" % json.dumps(QUOTE_BREAKOUT),
|
||||
})
|
||||
for markup in out.values():
|
||||
self._assert_inert(markup, allowed_tags={"a", "b"})
|
||||
self.assertEqual(
|
||||
sorted(name for name, _ in _parse(markup).attrs),
|
||||
["href", "rel", "target"],
|
||||
"unexpected attributes on the anchor: %r" % (markup,),
|
||||
)
|
||||
|
||||
def test_d4_name_href_rejects_dangerous_schemes(self):
|
||||
out = self._eval({
|
||||
"js": "nameFormatter('Model', {reference: 'javascript:alert(1)'})",
|
||||
"obfuscated": r"nameFormatter('Model', {reference: 'java\tscript:alert(1)'})",
|
||||
"entity": "nameFormatter('Model', {reference: 'javascript:alert(1)'})",
|
||||
"data": "nameFormatter('Model', {reference: 'data:text/html,<script>alert(1)</script>'})",
|
||||
"ok": "nameFormatter('Model', {reference: 'https://huggingface.co/repo'})",
|
||||
})
|
||||
for key in ("js", "obfuscated", "data"):
|
||||
href = dict(_parse(out[key]).attrs)["href"]
|
||||
self.assertEqual(href, "#", "%s: dangerous scheme survived as %r" % (key, href))
|
||||
# The entity form is defeated by the attribute escape, not the scheme
|
||||
# check: `&` becomes `&`, so `:` reaches the browser as text.
|
||||
self.assertIn("&#58;", out["entity"])
|
||||
self.assertEqual(dict(_parse(out["ok"]).attrs)["href"], "https://huggingface.co/repo")
|
||||
|
||||
# --- D-V: url column, quoted but previously unescaped -------------------
|
||||
def test_d5_url_href_escaped_inside_its_quotes(self):
|
||||
out = self._eval({
|
||||
"quote": "urlFormatter(%s)" % json.dumps(QUOTE_BREAKOUT),
|
||||
"attr": "urlFormatter(%s)" % json.dumps(ATTR_BREAKOUT),
|
||||
"js": "urlFormatter('javascript:alert(1)')",
|
||||
})
|
||||
self._assert_inert(out["quote"], allowed_tags={"a", "svg", "path"})
|
||||
self._assert_inert(out["attr"], allowed_tags={"a", "svg", "path"})
|
||||
self.assertEqual(dict(_parse(out["js"]).attrs)["href"], "#")
|
||||
|
||||
# --- D-III: the four previously formatter-less columns ------------------
|
||||
def test_d3_columns_bind_the_escaping_formatter(self):
|
||||
for column in ("'type'", "'base'", '"save_path"', "'filename'"):
|
||||
block = _slice_column(column)
|
||||
self.assertIn(
|
||||
"formatter: escapeCell", block,
|
||||
"column %s still renders RAW model-list.json into innerHTML" % column,
|
||||
)
|
||||
|
||||
def test_d3_escape_cell_neutralises_payload_and_keeps_empty_cells(self):
|
||||
out = _run_node(
|
||||
"%s\nconsole.log(JSON.stringify({"
|
||||
"payload: escapeCell(%s),"
|
||||
"nul: escapeCell(null), undef: escapeCell(undefined),"
|
||||
"num: escapeCell(42), plain: escapeCell('checkpoints')"
|
||||
"}));\n" % (self.preamble, json.dumps(TEXT_PAYLOAD))
|
||||
)
|
||||
self._assert_inert(out["payload"], allowed_tags=set())
|
||||
self.assertEqual(html.unescape(out["payload"]), TEXT_PAYLOAD,
|
||||
"the payload must survive as visible text")
|
||||
# null/undefined pass through so turbogrid keeps rendering an empty cell.
|
||||
self.assertIsNone(out["nul"])
|
||||
self.assertNotIn("undef", out)
|
||||
self.assertEqual(out["num"], "42")
|
||||
self.assertEqual(out["plain"], "checkpoints")
|
||||
|
||||
# --- D-VI: the showStatus message sink ----------------------------------
|
||||
def test_d6_show_status_renders_item_name_inert(self):
|
||||
"""item.name must reach .cmm-manager-status innerHTML inert.
|
||||
|
||||
This used to assert that the CALL SITE carried a sanitizeHTML. That was
|
||||
wrong in a way the assertion could not see: ``item.name`` arrives from
|
||||
``/externalmodel/getlist`` ALREADY escaped by ``populate_markdown`` ->
|
||||
``sanitize_tag``, so escaping it again rendered ``<Flux>`` to the
|
||||
user. The call-site escape was removed for that reason, and the sink
|
||||
deliberately does not escape either -- see
|
||||
tests/test_message_sink_provenance.py for why a blanket sink escape
|
||||
cannot be correct across these callers.
|
||||
|
||||
The GUARANTEE is unchanged, and is now asserted end-to-end on the value
|
||||
the caller ACTUALLY receives: take the server's own output for a hostile
|
||||
name, push it through the REAL lifted sink, and read innerHTML. That is
|
||||
strictly stronger than the old form, which passed whether or not the
|
||||
rendered result was inert.
|
||||
"""
|
||||
anchor = "this.showStatus(`Install "
|
||||
line = MODEL_MANAGER_SRC[MODEL_MANAGER_SRC.index(anchor):]
|
||||
line = line[:line.index("\n")]
|
||||
self.assertNotIn(
|
||||
"sanitizeHTML", line,
|
||||
"item.name is already server-escaped; escaping here is the mojibake "
|
||||
"bug this call site used to have: %r" % (line,),
|
||||
)
|
||||
# What populate_markdown -> sanitize_tag puts on the wire for the payload.
|
||||
server_value = TEXT_PAYLOAD.replace("<", "<").replace(">", ">")
|
||||
lifted = "function " + slice_braced(MODEL_MANAGER_SRC, "\tshowStatus(msg, color)")
|
||||
rendered = _run_node(
|
||||
"%s\nconst sink = %s;\n"
|
||||
"const item = {name: %s};\n"
|
||||
"const el = { innerHTML: '' };\n"
|
||||
"const ctx = { element: { querySelector: () => el } };\n"
|
||||
"sink.call(ctx, `Install ${item.name} ...`);\n"
|
||||
"console.log(JSON.stringify({msg: el.innerHTML}));\n"
|
||||
% (_lift_sanitize_html(), lifted, json.dumps(server_value))
|
||||
)
|
||||
self._assert_inert(rendered["msg"], allowed_tags=set())
|
||||
# ...and the user reads the payload as text, not as entity soup.
|
||||
self.assertEqual(
|
||||
html.unescape(rendered["msg"]), "Install %s ..." % TEXT_PAYLOAD
|
||||
)
|
||||
|
||||
# --- filter dropdowns: same raw type/base values, different route -------
|
||||
def test_filter_dropdown_options_escape_label_and_value(self):
|
||||
out = _run_node(
|
||||
"%s\nconsole.log(JSON.stringify({"
|
||||
"label: renderOptions([{label: %s, value: 'x'}], ''),"
|
||||
"value: renderOptions([{label: 'x', value: %s}], ''),"
|
||||
"plain: renderOptions([{label: 'checkpoints', value: 'checkpoints'}], 'checkpoints')"
|
||||
"}));\n" % (self.preamble, json.dumps(TEXT_PAYLOAD), json.dumps(ATTR_BREAKOUT))
|
||||
)
|
||||
self._assert_inert(out["label"], allowed_tags={"option"})
|
||||
self._assert_inert(out["value"], allowed_tags={"option"})
|
||||
# Selection still matches on the RAW value, and benign data is untouched.
|
||||
self.assertIn(" selected", out["plain"])
|
||||
self.assertIn(">checkpoints</option>", out["plain"])
|
||||
|
||||
def test_update_filter_routes_all_three_dropdowns_through_render_options(self):
|
||||
block = slice_braced(MODEL_MANAGER_SRC, "updateFilter()")
|
||||
for dropdown in ("$filter", "$type", "$base"):
|
||||
self.assertIn(
|
||||
"%s.innerHTML = renderOptions(" % dropdown, block,
|
||||
"%s still builds <option> markup inline" % dropdown,
|
||||
)
|
||||
|
||||
# --- description: server-transformed HTML, must NOT be double-escaped ---
|
||||
def test_description_column_is_not_re_escaped(self):
|
||||
block = _slice_column("'description'")
|
||||
self.assertNotIn(
|
||||
"formatter", block,
|
||||
"description is server-transformed HTML (populate_markdown); re-escaping "
|
||||
"it renders visible mojibake such as 'Nodes for <Flux> workflows'",
|
||||
)
|
||||
|
||||
def test_description_server_value_still_reads_cleanly(self):
|
||||
# What populate_markdown -> sanitize_tag actually emits for "<Flux>".
|
||||
server_value = "Nodes for <Flux> workflows"
|
||||
self.assertEqual(html.unescape(server_value), "Nodes for <Flux> workflows")
|
||||
# Run through the client escaper as well, the user would see the entity
|
||||
# text instead of the angle brackets — that is what this guard forbids.
|
||||
double = _run_node(
|
||||
"%s\nconsole.log(JSON.stringify({v: sanitizeHTML(%s)}));\n"
|
||||
% (_lift_sanitize_html(), json.dumps(server_value))
|
||||
)["v"]
|
||||
self.assertEqual(html.unescape(double), server_value)
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class ModelManagerMessageSinkTest(unittest.TestCase):
|
||||
"""model-manager's OWN message sinks — it does not use createUIStateManager.
|
||||
|
||||
This class replaces an earlier ``test_turbogrid_bundle_untouched`` guard,
|
||||
which asserted that ``js/turbogrid.esm.js`` was unchanged relative to a
|
||||
fixed base commit. That was an INTEGRATION ARTIFACT, not a property of this
|
||||
file: it encoded "only model-manager was touched by the work item that
|
||||
added these tests". In the integrated tree the vendored grid IS changed on
|
||||
purpose, so the assertion reported a cross-file fact that says nothing
|
||||
about whether model-manager's own sinks are safe. What this file can
|
||||
actually guarantee is asserted instead — and it is strictly more coverage,
|
||||
because the sinks below were not covered by anything before.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.sanitize = _lift_sanitize_html()
|
||||
|
||||
def _call_sink(self, method: str, msg, color=None) -> str:
|
||||
"""Run one lifted sink against a stub element; return the real innerHTML."""
|
||||
lifted = "function " + slice_braced(MODEL_MANAGER_SRC, "\t%s(msg, color)" % method)
|
||||
args = json.dumps(msg) + ("" if color is None else ", " + json.dumps(color))
|
||||
return _run_node(
|
||||
"%s\n"
|
||||
"const sink = %s;\n"
|
||||
"const el = { innerHTML: '' };\n"
|
||||
"const ctx = { element: { querySelector: () => el } };\n"
|
||||
"sink.call(ctx, %s);\n"
|
||||
"console.log(JSON.stringify({v: el.innerHTML}));\n"
|
||||
% (self.sanitize, lifted, args)
|
||||
)["v"]
|
||||
|
||||
# --- the sinks take DISPLAY-READY text and do NOT escape ---------------
|
||||
def test_the_sinks_deliberately_do_not_escape(self):
|
||||
# Structural, and load-bearing: a well-meaning sanitizeHTML added here
|
||||
# would double-escape the server-escaped values these sinks actually
|
||||
# receive. tests/test_message_sink_provenance.py is what keeps that safe
|
||||
# by classifying every caller.
|
||||
for method in ("showStatus(msg, color)", "showMessage(msg, color)"):
|
||||
body = slice_braced(MODEL_MANAGER_SRC, "\t" + method)
|
||||
self.assertNotIn(
|
||||
"sanitizeHTML", body,
|
||||
"%s escapes at the sink; its callers pass ALREADY-escaped server "
|
||||
"values, so this renders '<Flux>' to the user" % method,
|
||||
)
|
||||
self.assertIn("innerHTML", body)
|
||||
|
||||
def test_a_server_escaped_value_renders_inert_through_the_sink(self):
|
||||
server_value = "Install %s ..." % TEXT_PAYLOAD.replace(
|
||||
"<", "<").replace(">", ">")
|
||||
rendered = self._call_sink("showStatus", server_value)
|
||||
self.assertNotIn("img", _parse(rendered).tags)
|
||||
|
||||
def test_a_benign_angle_bracket_name_is_not_double_escaped(self):
|
||||
# THE regression this arrangement exists to prevent. The server sends
|
||||
# 'Model for <Flux> workflows'; the user must read the angle
|
||||
# brackets, not the entities.
|
||||
server_value = "Install Model for <Flux> workflows ..."
|
||||
rendered = self._call_sink("showStatus", server_value)
|
||||
self.assertEqual(
|
||||
html.unescape(rendered), "Install Model for <Flux> workflows ..."
|
||||
)
|
||||
self.assertNotIn("&lt;", rendered)
|
||||
|
||||
def test_the_font_wrapper_still_renders(self):
|
||||
rendered = self._call_sink("showMessage", "plain text", "red")
|
||||
collector = _parse(rendered)
|
||||
self.assertIn("font", collector.tags)
|
||||
self.assertIn(("color", "red"), collector.attrs)
|
||||
|
||||
# --- showError: HTML BY CONTRACT, escaped by its callers ---------------
|
||||
def test_show_error_forwards_to_the_non_escaping_show_message(self):
|
||||
# showError is HTML by contract: its errorMsg is built escaped because
|
||||
# the SAME string also goes to ComfyUI-core show_message(). Forwarding
|
||||
# to a NON-escaping showMessage keeps that at exactly one escape.
|
||||
body = slice_braced(MODEL_MANAGER_SRC, "\tshowError(err)")
|
||||
self.assertIn("this.showMessage(err, \"red\")", body)
|
||||
|
||||
# --- the caller side ---------------------------------------------------
|
||||
def test_the_install_status_caller_passes_the_server_value_untouched(self):
|
||||
self.assertIn("this.showStatus(`Install ${item.name} ...`)", MODEL_MANAGER_SRC)
|
||||
|
||||
def test_the_install_error_message_escapes_only_the_raw_response(self):
|
||||
# The name is already escaped by the server; the error response is raw.
|
||||
self.assertIn(
|
||||
"errorMsg = `'${item.name}': `", MODEL_MANAGER_SRC
|
||||
)
|
||||
self.assertIn("errorMsg += sanitizeHTML(await res.text())", MODEL_MANAGER_SRC)
|
||||
|
||||
def test_install_failure_preserves_server_escaped_names_in_both_sinks(self):
|
||||
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
|
||||
methods = ",\n".join(slice_braced(MODEL_MANAGER_SRC, marker) for marker in [
|
||||
"async installModels(list, btn)", "showError(err)", "showMessage(msg, color)", "showStatus(msg, color)"
|
||||
])
|
||||
response_text = '<img src=x onerror="window.__probe=1"> download failed'
|
||||
for name in ["Model <Flux>", '<img src=x onerror="window.__probe=1">']:
|
||||
for status in [403, 500]:
|
||||
with self.subTest(name=name, status=status):
|
||||
item = {"name": name, "originalData": {}, "hash": "model"}
|
||||
populate(item)
|
||||
rendered = _run_node("""
|
||||
%s
|
||||
const messages = {};
|
||||
const elements = {};
|
||||
const api = {fetchApi: async (url) => url.endsWith('/status')
|
||||
? {json: async () => ({is_processing: false})}
|
||||
: {status: %d, json: async () => ({}), text: async () => %s}};
|
||||
const show_message = (message) => messages.dialog = message;
|
||||
const ctx = {
|
||||
%s,
|
||||
element: {querySelector: (selector) => elements[selector] ||= {innerHTML: ''}},
|
||||
grid: {scrollRowIntoView() {}, updateCell() {}},
|
||||
focusInstall() {return true;}
|
||||
};
|
||||
await ctx.installModels([%s], {classList: {add() {}}});
|
||||
messages.panel = elements['.cmm-manager-message'].innerHTML;
|
||||
console.log(JSON.stringify(messages));
|
||||
""" % (self.sanitize, status, json.dumps(response_text), methods, json.dumps(item)))
|
||||
self.assertEqual(set(rendered), {"panel", "dialog"})
|
||||
for message in rendered.values():
|
||||
self.assertIn("'%s': " % name, html.unescape(message))
|
||||
self.assertNotIn("img", _parse(message).tags)
|
||||
if status == 500:
|
||||
self.assertIn(response_text, html.unescape(message))
|
||||
|
||||
def test_the_queue_result_error_text_is_escaped_by_the_caller(self):
|
||||
self.assertIn("errorMsg += sanitizeHTML(String(v))", MODEL_MANAGER_SRC)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,286 @@
|
||||
"""RED->GREEN guard for the js/node-usage-analyzer.js innerHTML sinks.
|
||||
|
||||
The analyzer writes pack data and workflow data into innerHTML through turbogrid
|
||||
cells, a flyover, and the status/message elements. Before the fix: the title
|
||||
column built an UNQUOTED href (a bare space breaks out of the attribute); the
|
||||
install-progress showStatus interpolated a raw pack key; the usage-details
|
||||
flyover interpolated a raw workflow filename; and the install error path
|
||||
interpolated both a raw pack key and raw server text.
|
||||
|
||||
One value needed fixing at its SOURCE rather than at the sink. The title column
|
||||
is built as `pack.title || packKey`: /customnode/getlist runs populate_markdown
|
||||
over each pack, so `pack.title` is server-escaped, but `packKey` is a dict key
|
||||
that no server transform touches. Escaping at the formatter would have
|
||||
double-escaped the common path into visible entity text ("Nodes for <Flux>
|
||||
workflows"); leaving it bare would have shipped the raw fallback. The fix
|
||||
normalises the fallback so the column carries ONE provenance, which is what
|
||||
makes the bare formatter correct by construction.
|
||||
|
||||
This grid uses cellResizeObserver only — no rowFilter, no
|
||||
highlightKeywordsFilter — so the keyword-highlight re-render does not reach it.
|
||||
The two searchable managers use js/manager-grid.js to secure that path without
|
||||
modifying the vendored TurboGrid bundle.
|
||||
|
||||
The status/message sinks take DISPLAY-READY text and do NOT escape, because
|
||||
their callers carry mixed provenance; every caller is classified in
|
||||
tests/test_message_sink_provenance.py. This analyzer is the only file that
|
||||
reaches BOTH sink implementations — the shared one from common.js via `this.ui`
|
||||
and its own class methods.
|
||||
|
||||
REPRODUCE THE RED HALF against the pre-fix revision — one command:
|
||||
mkdir -p /some/dir
|
||||
git show <base>:js/node-usage-analyzer.js > /some/dir/node-usage-analyzer.js
|
||||
git show <base>:js/common.js > /some/dir/common.js
|
||||
MANAGER_JS_DIR=/some/dir pytest tests/test_node_usage_escaping.py
|
||||
"""
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from js_lift import (
|
||||
NODE,
|
||||
JsSource,
|
||||
event_handlers,
|
||||
parse,
|
||||
run_node,
|
||||
slice_braced,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
|
||||
|
||||
ANALYZER = "node-usage-analyzer.js"
|
||||
COMMON = "common.js"
|
||||
TITLE_FORMATTER_ANCHOR = "formatter: function (name, rowItem, columnItem, cellNode)"
|
||||
|
||||
BREAKOUT_HREF = "https://evil.example/a onmouseover=alert(1) x"
|
||||
QUOTE_BREAKOUT = 'https://evil.example/a"><img src=x onerror=alert(1)>'
|
||||
# An ordinary pack reference, for the legs that ask what a CORRECT link
|
||||
# looks like rather than what a hostile one cannot do.
|
||||
BENIGN_REFERENCE = "https://github.com/owner/repo"
|
||||
TEXT_PAYLOAD = "<img src=x onerror=alert(1)>"
|
||||
|
||||
|
||||
def _url_helper_home() -> str:
|
||||
"""Which file currently owns sanitizeUrl/safeHref, or '' on a pre-fix tree.
|
||||
|
||||
They were local to the analyzer when this guard was written and now live in
|
||||
common.js, imported by every client file. Resolving the home rather than
|
||||
hard-coding it keeps the MANAGER_JS_DIR reproduce working against both
|
||||
revisions, and makes a THIRD home fail loudly instead of being lifted twice.
|
||||
"""
|
||||
for filename in (COMMON, ANALYZER):
|
||||
if "SAFE_URL_SCHEMES" in JS.text(filename):
|
||||
return filename
|
||||
return ""
|
||||
|
||||
|
||||
def _preamble() -> str:
|
||||
parts = [JS.lift_declaration(COMMON, "export function sanitizeHTML(")]
|
||||
home = _url_helper_home()
|
||||
if home:
|
||||
span = JS.lift_span(
|
||||
home, "SAFE_URL_SCHEMES", "safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
|
||||
parts.append("const " + span.replace("export ", ""))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class NodeUsageSinkEscapingTest(unittest.TestCase):
|
||||
"""Every sink is exercised through the REAL lifted code, never a copy."""
|
||||
|
||||
def _assert_inert(self, markup: str, allowed_tags):
|
||||
"""No element outside the allow-list, and no event-handler attribute."""
|
||||
parsed = parse(markup)
|
||||
self.assertEqual(
|
||||
[t for t in parsed.tags if t not in allowed_tags], [],
|
||||
"live element injected into: %r" % (markup,),
|
||||
)
|
||||
self.assertEqual(
|
||||
event_handlers(markup), [],
|
||||
"event-handler attribute injected into: %r" % (markup,),
|
||||
)
|
||||
|
||||
# --- C2: title column, previously UNQUOTED href -------------------------
|
||||
def _render_title(self, reference: str, name: str = "My Pack") -> str:
|
||||
return run_node(
|
||||
"%s\nconst titleFormatter = %s;\n"
|
||||
"console.log(JSON.stringify({markup: titleFormatter(%s, {reference: %s})}));\n"
|
||||
% (_preamble(),
|
||||
JS.lift_formatter(ANALYZER, TITLE_FORMATTER_ANCHOR),
|
||||
json.dumps(name), json.dumps(reference))
|
||||
)["markup"]
|
||||
|
||||
def test_c2_href_survives_breakout_attempts(self):
|
||||
for reference in (BREAKOUT_HREF, QUOTE_BREAKOUT):
|
||||
markup = self._render_title(reference)
|
||||
self._assert_inert(markup, allowed_tags={"a", "b"})
|
||||
self.assertEqual(
|
||||
sorted(name for name, _ in parse(markup).attrs),
|
||||
["href", "rel", "target"],
|
||||
"unexpected attributes on the anchor: %r" % (markup,),
|
||||
)
|
||||
|
||||
def test_c2_title_anchor_severs_the_opener(self):
|
||||
"""The pack chooses this host, so the page it opens must not keep us.
|
||||
|
||||
`reference` is channel data, so the pack author picks the page that
|
||||
opens; without rel that page holds a live window.opener back into the
|
||||
Manager UI. Membership on the SPLIT attribute, not a literal match:
|
||||
'noreferrer noopener' is the same thing, and pinning token order would
|
||||
report a correct fix as a defect.
|
||||
"""
|
||||
markup = self._render_title(BENIGN_REFERENCE)
|
||||
attrs = dict(parse(markup).attrs)
|
||||
self.assertEqual(
|
||||
attrs.get("target"), "_blank",
|
||||
"this guard is about _blank anchors; the target changed, so "
|
||||
"re-derive it rather than deleting the assertion: %r" % (markup,))
|
||||
self.assertIn(
|
||||
"rel", attrs,
|
||||
"the title anchor opens a pack-chosen page in a new tab without "
|
||||
"rel, so that page keeps a reference to this window: %r" % (markup,))
|
||||
tokens = attrs["rel"].lower().split()
|
||||
self.assertIn("noopener", tokens, markup)
|
||||
self.assertIn("noreferrer", tokens, markup)
|
||||
|
||||
def test_c2_href_rejects_dangerous_schemes(self):
|
||||
for reference in ("javascript:alert(1)", "java\tscript:alert(1)",
|
||||
"data:text/html,<script>alert(1)</script>"):
|
||||
href = dict(parse(self._render_title(reference)).attrs)["href"]
|
||||
self.assertEqual(href, "#", "%r survived as %r" % (reference, href))
|
||||
|
||||
def test_c2_benign_reference_and_title_are_untouched(self):
|
||||
markup = self._render_title(BENIGN_REFERENCE, name="My Pack")
|
||||
self.assertEqual(dict(parse(markup).attrs)["href"], BENIGN_REFERENCE)
|
||||
self.assertIn("<b>My Pack</b>", markup)
|
||||
|
||||
def test_c2_title_is_not_double_escaped_at_the_formatter(self):
|
||||
# The column value is already escaped HTML text by the time it arrives,
|
||||
# so re-escaping here would put entity text on screen.
|
||||
block = JS.lift_formatter(ANALYZER, TITLE_FORMATTER_ANCHOR)
|
||||
self.assertNotIn(
|
||||
"sanitizeHTML(String(name))", block,
|
||||
"title is normalised at its source; escaping it again renders '<' to the user",
|
||||
)
|
||||
server_value = "Nodes for <Flux> workflows"
|
||||
markup = self._render_title("https://example.com/x", name=server_value)
|
||||
self.assertIn(server_value, markup)
|
||||
self.assertEqual(html.unescape(server_value), "Nodes for <Flux> workflows")
|
||||
|
||||
def _run_panel(self, action, packs=None, status=500):
|
||||
"""Run the production data, operation and rendering methods with stubbed I/O."""
|
||||
methods = ",\n".join(slice_braced(JS.text(ANALYZER), "\n\t" + marker) for marker in [
|
||||
"async loadData()", "async installModels(list, btn)", "async uninstallModels(list, btn)",
|
||||
"showUsageDetails(rowItem)", "showError(err)", "showMessage(msg, color)", "showStatus(msg, color)",
|
||||
])
|
||||
return run_node("""
|
||||
%s
|
||||
%s
|
||||
%s
|
||||
const elements = {};
|
||||
const messages = {};
|
||||
const posts = [];
|
||||
const manager_instance = {datasrc_combo: {value: 'default'}};
|
||||
const fetchData = async () => ({data: {channel: 'default', node_packs: %s}});
|
||||
const analyzeWorkflowUsage = async () => ({success: true});
|
||||
const api = {fetchApi: async (url, options) => {
|
||||
if (options?.body) posts.push(JSON.parse(options.body));
|
||||
return {status: %d, json: async () => ({is_processing: false}), text: async () => %s};
|
||||
}};
|
||||
const customConfirm = async () => true;
|
||||
const md5 = () => 'hash';
|
||||
const show_message = message => messages.dialog = message;
|
||||
const btn = {classList: {add() {}, remove() {}}};
|
||||
const ctx = {
|
||||
%s,
|
||||
element: {querySelector: selector => elements[selector] ||= {innerHTML: '', style: {}}},
|
||||
grid: {scrollRowIntoView() {}, updateCell() {}, updateRow() {}},
|
||||
flyover: {show: (title, body) => Object.assign(messages, {title, body})},
|
||||
showLoading() {}, hideLoading() {}, renderGrid() {}, getModelList(rows) {return rows;}
|
||||
};
|
||||
ctx.ui = createUIStateManager(ctx.element, {
|
||||
status: '.nu-manager-status', message: '.nu-manager-message'
|
||||
});
|
||||
%s
|
||||
console.log(JSON.stringify({elements, messages, posts, rows: ctx.modelList}));
|
||||
""" % (_preamble(),
|
||||
JS.lift_declaration(COMMON, "export function createUIStateManager(element, selectors)"),
|
||||
JS.lift_declaration(COMMON, "export async function uninstallNodes(nodeList, options = {})"),
|
||||
json.dumps(packs or {}), status, json.dumps(TEXT_PAYLOAD), methods, action))
|
||||
|
||||
def test_title_fallback_is_escaped_at_its_source(self):
|
||||
out = self._run_panel("await ctx.loadData();", {TEXT_PAYLOAD: {"state": "enabled"}})
|
||||
row = out["rows"][0]
|
||||
self._assert_inert(row["title"], allowed_tags=set())
|
||||
self.assertEqual(html.unescape(row["title"]), TEXT_PAYLOAD)
|
||||
self.assertEqual(row["name"], TEXT_PAYLOAD)
|
||||
|
||||
def test_install_progress_and_errors_escape_raw_values(self):
|
||||
for status in (403, 500):
|
||||
with self.subTest(status=status):
|
||||
out = self._run_panel("await ctx.loadData(); await ctx.installModels(ctx.modelList, btn);",
|
||||
{TEXT_PAYLOAD: {"state": "enabled"}}, status)
|
||||
progress = out["elements"][".nu-manager-status"]["innerHTML"]
|
||||
self._assert_inert(progress, allowed_tags=set())
|
||||
self.assertEqual(html.unescape(progress), f"Install {TEXT_PAYLOAD} ...")
|
||||
errors = [out["elements"][".nu-manager-message"]["innerHTML"], out["messages"]["dialog"]]
|
||||
for error in errors:
|
||||
self._assert_inert(error, allowed_tags={"font"})
|
||||
self.assertIn(f"'{TEXT_PAYLOAD}': ", html.unescape(error))
|
||||
if status == 500:
|
||||
self.assertIn(": " + TEXT_PAYLOAD, html.unescape(error))
|
||||
|
||||
def test_flyover_escapes_workflow_filename(self):
|
||||
for filename in (TEXT_PAYLOAD, "workflow <Flux> & friends.json"):
|
||||
with self.subTest(filename=filename):
|
||||
out = self._run_panel("ctx.showUsageDetails(%s);" % json.dumps({
|
||||
"title": "My Pack", "workflowDetails": [{"filename": filename, "nodeCount": 2}],
|
||||
}))
|
||||
body = out["messages"]["body"]
|
||||
self._assert_inert(body, allowed_tags={"div"})
|
||||
self.assertIn(filename, html.unescape(body))
|
||||
self.assertIn("2 nodes", body)
|
||||
|
||||
def test_uninstall_failure_keeps_normalized_titles_readable(self):
|
||||
for name in ("Pack <Flux> & friends", TEXT_PAYLOAD):
|
||||
for has_title in (False, True):
|
||||
for status in (403, 500):
|
||||
with self.subTest(name=name, has_title=has_title, status=status):
|
||||
pack = {"state": "enabled"}
|
||||
if has_title:
|
||||
pack["title"] = html.escape(name)
|
||||
out = self._run_panel("await ctx.loadData(); await ctx.uninstallModels(ctx.modelList, btn);",
|
||||
{name: pack}, status)
|
||||
progress = out["elements"][".nu-manager-status"]["innerHTML"]
|
||||
self.assertEqual(html.unescape(progress), f"Uninstall {name} ...")
|
||||
errors = [out["elements"][".nu-manager-message"]["innerHTML"], out["messages"]["dialog"]]
|
||||
for error in errors:
|
||||
self._assert_inert(error, allowed_tags={"font"})
|
||||
self.assertIn(f"'{name}': ", html.unescape(error))
|
||||
if status == 500:
|
||||
self.assertIn(": " + TEXT_PAYLOAD, html.unescape(error))
|
||||
|
||||
def test_uninstall_helper_escapes_a_raw_name_fallback(self):
|
||||
out = self._run_panel("""
|
||||
await uninstallNodes([{name: %s}], {
|
||||
onProgress: msg => messages.progress = msg,
|
||||
onError: msg => messages.error = msg
|
||||
});
|
||||
""" % json.dumps(TEXT_PAYLOAD))
|
||||
for message in out["messages"].values():
|
||||
self._assert_inert(message, allowed_tags=set())
|
||||
self.assertIn(TEXT_PAYLOAD, html.unescape(message))
|
||||
self.assertEqual(out["posts"][0]["name"], TEXT_PAYLOAD)
|
||||
|
||||
def test_grid_has_no_keyword_highlight_path(self):
|
||||
# The premise the row states: this grid cannot hit the highlight defect.
|
||||
source = JS.text(ANALYZER)
|
||||
self.assertNotIn("highlightKeywordsFilter", source)
|
||||
self.assertNotIn("rowFilter:", source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Exercise notice dependency installation without starting ComfyUI or pip."""
|
||||
import ast
|
||||
import builtins
|
||||
import importlib.metadata
|
||||
import itertools
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, mock_open, patch
|
||||
|
||||
import toml
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
from manager_test_utils import load_manager_util
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class NoticeDependencyTest(unittest.TestCase):
|
||||
def test_util_can_load_before_nh3_is_installed(self):
|
||||
with patch.dict(sys.modules, {'nh3': None}):
|
||||
util = load_manager_util()
|
||||
self.assertEqual(util.escape_html_attribute('<text>'), '<text>')
|
||||
with self.assertRaises(ModuleNotFoundError):
|
||||
util.sanitize_html_fragment('<p>notice</p>')
|
||||
|
||||
def test_bootstrap_installs_missing_or_outdated_nh3_before_importing_it(self):
|
||||
manifest = (ROOT / 'requirements.txt').read_text()
|
||||
commented_manifest = '\n# Dependencies\n--index-url https://pypi.org/simple\n' + manifest.replace(
|
||||
'nh3>=0.3.7', ' nh3>=0.3.7 # notice sanitizer',
|
||||
)
|
||||
for installed_version, requirements in itertools.product(
|
||||
(None, '0.3.0', '0.3.7rc1', '0.3.7', '0.4.0'), (manifest, commented_manifest),
|
||||
):
|
||||
with self.subTest(installed_version=installed_version, commented=requirements == commented_manifest):
|
||||
source = (ROOT / 'prestartup_script.py').read_text()
|
||||
node = next(n for n in ast.parse(source).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == 'ensure_dependencies')
|
||||
installer = Mock()
|
||||
namespace = {
|
||||
'__file__': str(ROOT / 'prestartup_script.py'),
|
||||
'os': os,
|
||||
're': re,
|
||||
'subprocess': types.SimpleNamespace(
|
||||
check_output=installer, CalledProcessError=subprocess.CalledProcessError,
|
||||
),
|
||||
'manager_util': types.SimpleNamespace(make_pip_cmd=lambda args: args),
|
||||
}
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), '<bootstrap>', 'exec'), namespace)
|
||||
needs_install = installed_version in (None, '0.3.0', '0.3.7rc1')
|
||||
modules = {name: types.ModuleType(name) for name in ('git', 'toml', 'rich', 'chardet')}
|
||||
# An old native module must not be imported before pip upgrades it.
|
||||
modules['nh3'] = None if needs_install else types.ModuleType('nh3')
|
||||
failure = importlib.metadata.PackageNotFoundError('nh3') if installed_version is None else None
|
||||
with patch.dict(sys.modules, modules), patch(
|
||||
'importlib.metadata.version', return_value=installed_version, side_effect=failure,
|
||||
), patch('builtins.open', mock_open(read_data=requirements)), patch('builtins.print'), patch(
|
||||
'builtins.__import__', wraps=builtins.__import__,
|
||||
) as imports:
|
||||
namespace['ensure_dependencies']()
|
||||
nh3_imports = [call for call in imports.call_args_list if call.args[0] == 'nh3']
|
||||
self.assertEqual(len(nh3_imports), 0 if needs_install else 1)
|
||||
if needs_install:
|
||||
installer.assert_called_once_with(['install', '-r', str(ROOT / 'requirements.txt')])
|
||||
else:
|
||||
installer.assert_not_called()
|
||||
|
||||
def test_dependency_manifests_agree_on_notice_requirements(self):
|
||||
lines = (re.split(r'\s+#', line.strip(), maxsplit=1)[0]
|
||||
for line in (ROOT / 'requirements.txt').read_text().splitlines())
|
||||
pip_requirements = {
|
||||
req.name: req for req in map(Requirement, (line for line in lines if line and not line.startswith(('#', '-'))))
|
||||
}
|
||||
project_requirements = {
|
||||
req.name: req for req in map(Requirement, toml.load(ROOT / 'pyproject.toml')['project']['dependencies'])
|
||||
}
|
||||
for name in ('nh3', 'packaging'):
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(str(pip_requirements[name]), str(project_requirements[name]))
|
||||
@@ -0,0 +1,372 @@
|
||||
"""RED->GREEN guard for the pack-grid column sinks in js/custom-nodes-manager.js.
|
||||
|
||||
WHY THESE THREE VALUES
|
||||
----------------------
|
||||
The Custom Nodes grid renders every cell through turbogrid, which hands a column
|
||||
formatter the RAW row value and assigns the formatter's return through
|
||||
``innerHTML``. Whatever a formatter returns unescaped is therefore live markup on
|
||||
FIRST RENDER, before any click, for every row the list contains.
|
||||
|
||||
Three values reached that sink unescaped, and all three carry the same provenance
|
||||
as ``author`` (which the neighbouring formatter in the same column array already
|
||||
escapes):
|
||||
|
||||
``stars`` and ``last_update`` are written by ``populate_github_stats`` in
|
||||
glob/manager_core.py straight off github-stats.json, with no transform.
|
||||
``populate_markdown`` -- the only server-side escape on this payload -- covers
|
||||
``description``, ``name`` and ``title`` and NOTHING else, and
|
||||
``populate_github_stats`` swallows a broken row with a bare ``except:``, so a
|
||||
channel-supplied ``stars`` / ``last_update`` survives untouched when
|
||||
``v['reference']`` raises. That double fact is pinned in
|
||||
``PopulateProvenanceTest`` below, because it is what makes escaping HERE
|
||||
correct rather than a double-escape.
|
||||
|
||||
``channel`` is the resolved channel alias from /customnode/getlist -- a key out
|
||||
of the channel-list config file, passed through untransformed into the
|
||||
"Channel: ..." banner's ``innerHTML``.
|
||||
|
||||
WHY THE OLD GUARDS DID NOT STOP THEM
|
||||
------------------------------------
|
||||
Neither ``stars`` guard was a guard. ``stars < 0`` is a NaN comparison for a
|
||||
string ('<img ...>' < 0 is false), and ``typeof stars === 'number'`` only chooses
|
||||
the toLocaleString branch -- so a string payload fell through to the bare
|
||||
``return stars;``. ``last_update`` looked defended by ``.split(' ')[0]``, but a
|
||||
space is not the only attribute separator a browser accepts: ``/`` works, and so
|
||||
do tab and newline, so ``<img/src=x/onerror=alert(1)>`` passes through the split
|
||||
whole. Escaping is what closes both; a smarter split would not.
|
||||
|
||||
REPRODUCE THE RED HALF against the pre-fix revision -- one command:
|
||||
mkdir -p /some/dir
|
||||
git show <base>:js/custom-nodes-manager.js > /some/dir/custom-nodes-manager.js
|
||||
git show <base>:js/common.js > /some/dir/common.js
|
||||
MANAGER_JS_DIR=/some/dir pytest tests/test_pack_column_escaping.py
|
||||
"""
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from js_lift import (
|
||||
NODE,
|
||||
JsSource,
|
||||
event_handlers,
|
||||
line_containing,
|
||||
parse,
|
||||
slice_object_entry,
|
||||
slice_braced,
|
||||
)
|
||||
from js_lift import run_node
|
||||
from manager_test_utils import load_manager_util, load_markdown_functions
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
|
||||
GLOB_DIR = Path(os.environ.get("MANAGER_GLOB_DIR") or (REPO_ROOT / "glob"))
|
||||
|
||||
MANAGER = "custom-nodes-manager.js"
|
||||
COMMON = "common.js"
|
||||
|
||||
STARS_ANCHOR = "id: 'stars',"
|
||||
LAST_UPDATE_ANCHOR = "id: 'last_update',"
|
||||
CHANNEL_ANCHOR = '.cn-manager-channel").innerHTML'
|
||||
|
||||
# A space-separated payload for the columns that never split, and a
|
||||
# SLASH-separated one for last_update, whose `.split(' ')[0]` used to read as a
|
||||
# defence. Neither needs `<` to be dangerous in an attribute, but both are
|
||||
# element injections here because the sink is element text.
|
||||
TEXT_PAYLOAD = "<img src=x onerror=alert(1)>"
|
||||
NO_SPACE_PAYLOAD = "<img/src=x/onerror=alert(1)>"
|
||||
TAB_PAYLOAD = "<img\tsrc=x\tonerror=alert(1)>"
|
||||
|
||||
|
||||
def _lift_get_time_ago() -> str:
|
||||
"""getTimeAgo, lifted by SPAN rather than by brace matching.
|
||||
|
||||
js_lift's brace scanner reads a lone ``/`` as a regex literal -- correct for
|
||||
every range it was written for -- and getTimeAgo DIVIDES
|
||||
(``Math.abs(diff) / divisor``), so ``slice_braced`` raises on it by design
|
||||
rather than handing back a wrong slice. The span is bounded by the
|
||||
function's own last statement instead, so a change to that statement fails
|
||||
loudly here rather than lifting a truncated body.
|
||||
"""
|
||||
span = JS.lift_span(COMMON, "export function getTimeAgo(", "'in a year', diff < 0);")
|
||||
return span.replace("export ", "", 1) + "\n}"
|
||||
|
||||
|
||||
def _preamble() -> str:
|
||||
"""sanitizeHTML and getTimeAgo, lifted from the shipped common.js."""
|
||||
return "\n".join([
|
||||
JS.lift_declaration(COMMON, "export function sanitizeHTML("),
|
||||
_lift_get_time_ago(),
|
||||
])
|
||||
|
||||
|
||||
def _render(formatter_source: str, value) -> str:
|
||||
return run_node(
|
||||
"%s\nconst formatter = %s;\n"
|
||||
"console.log(JSON.stringify({markup: String(formatter(%s))}));\n"
|
||||
% (_preamble(), formatter_source, json.dumps(value))
|
||||
)["markup"]
|
||||
|
||||
|
||||
class _InertAssertions(unittest.TestCase):
|
||||
def assert_inert(self, markup: str, allowed_tags):
|
||||
parsed = parse(markup)
|
||||
self.assertEqual(
|
||||
[t for t in parsed.tags if t not in allowed_tags], [],
|
||||
"live element injected into: %r" % (markup,),
|
||||
)
|
||||
self.assertEqual(
|
||||
event_handlers(markup), [],
|
||||
"event-handler attribute injected into: %r" % (markup,),
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class StarsColumnTest(_InertAssertions):
|
||||
"""The ★ column formatter, lifted and executed as shipped."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.formatter = JS.lift_formatter(MANAGER, STARS_ANCHOR)
|
||||
|
||||
def test_a_string_payload_renders_inert(self):
|
||||
for payload in (TEXT_PAYLOAD, NO_SPACE_PAYLOAD, TAB_PAYLOAD):
|
||||
with self.subTest(payload=payload):
|
||||
markup = self._markup(payload)
|
||||
self.assert_inert(markup, allowed_tags=set())
|
||||
self.assertEqual(
|
||||
html.unescape(markup), payload,
|
||||
"the payload must survive as READABLE TEXT, not be dropped",
|
||||
)
|
||||
|
||||
def _markup(self, value):
|
||||
return _render(self.formatter, value)
|
||||
|
||||
def test_a_number_still_renders_through_toLocaleString(self):
|
||||
"""The behaviour the fix must not disturb: a real star count is grouped.
|
||||
|
||||
Compared against node's OWN toLocaleString rather than a hard-coded
|
||||
"1,234", so the assertion does not encode the runtime's default locale.
|
||||
"""
|
||||
expected = run_node(
|
||||
"console.log(JSON.stringify({v: (1234567).toLocaleString()}));"
|
||||
)["v"]
|
||||
markup = self._markup(1234567)
|
||||
self.assertEqual(markup, expected)
|
||||
self.assertNotIn("&", markup, "a number must not come back entity-escaped")
|
||||
|
||||
def test_a_negative_count_is_still_na(self):
|
||||
# -1 is populate_github_stats' "no stats for this pack" sentinel.
|
||||
self.assertEqual(self._markup(-1), "N/A")
|
||||
|
||||
def test_zero_is_not_treated_as_missing(self):
|
||||
self.assertEqual(self._markup(0), "0")
|
||||
|
||||
def test_a_missing_value_still_renders_an_empty_cell(self):
|
||||
"""null / undefined pass through, as at every other escaped column.
|
||||
|
||||
turbogrid's renderNodeContent does `void 0===e&&(e="")` and then assigns
|
||||
`innerHTML`, which is [LegacyNullToEmptyString] -- so a formatter that
|
||||
passes null/undefined THROUGH yields an empty cell, while one that
|
||||
stringifies them first puts the literal words "undefined" / "null" on
|
||||
screen. That is the shape spelled out at the id formatter in this same
|
||||
file and at model-manager's escapeCell, both of which say so in a comment.
|
||||
|
||||
It is reachable on exactly the malformed-row path this file is about:
|
||||
populate_github_stats swallows a broken entry with a bare `except:`, so
|
||||
`stars` is simply ABSENT for a pack whose `reference` lookup raises.
|
||||
|
||||
Asserted through renderNodeContent's own semantics rather than on the
|
||||
formatter's return alone, because "returns undefined" and "renders an
|
||||
empty cell" are different claims and it is the second one the user sees.
|
||||
"""
|
||||
out = run_node(
|
||||
"%s\nconst formatter = %s;\n"
|
||||
"const cell = (v) => { let e = formatter(v); if (void 0 === e) e = '';\n"
|
||||
" return e === null ? '' : String(e); };\n"
|
||||
"console.log(JSON.stringify({\n"
|
||||
" undefined_passthrough: formatter(undefined) === undefined,\n"
|
||||
" null_passthrough: formatter(null) === null,\n"
|
||||
" undefined_cell: cell(undefined), null_cell: cell(null)}));\n"
|
||||
% (_preamble(), self.formatter)
|
||||
)
|
||||
self.assertTrue(
|
||||
out["undefined_passthrough"],
|
||||
"a missing star count is stringified instead of passed through, so "
|
||||
"the cell reads 'undefined' where it used to be empty",
|
||||
)
|
||||
self.assertTrue(out["null_passthrough"], "null is stringified, not passed through")
|
||||
self.assertEqual(out["undefined_cell"], "")
|
||||
self.assertEqual(out["null_cell"], "")
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class LastUpdateColumnTest(_InertAssertions):
|
||||
"""The Last Update column formatter, lifted and executed as shipped."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.formatter = JS.lift_formatter(MANAGER, LAST_UPDATE_ANCHOR)
|
||||
|
||||
def _markup(self, value):
|
||||
return _render(self.formatter, value)
|
||||
|
||||
def test_a_slash_separated_payload_renders_inert(self):
|
||||
"""The case `.split(' ')[0]` cannot touch: no space anywhere in it."""
|
||||
markup = self._markup(NO_SPACE_PAYLOAD)
|
||||
self.assert_inert(markup, allowed_tags={"span"})
|
||||
|
||||
def test_separator_variants_render_inert(self):
|
||||
for payload in (TEXT_PAYLOAD, NO_SPACE_PAYLOAD, TAB_PAYLOAD):
|
||||
with self.subTest(payload=payload):
|
||||
self.assert_inert(self._markup(payload), allowed_tags={"span"})
|
||||
|
||||
def test_the_title_attribute_cannot_be_broken_out_of(self):
|
||||
# `ago` is not escaped, deliberately: getTimeAgo returns "" or a phrase
|
||||
# off a fixed table and never echoes its input. This pins that premise
|
||||
# behaviourally rather than trusting it.
|
||||
markup = self._markup('x" onmouseover=alert(1) y')
|
||||
self.assertEqual(event_handlers(markup), [], markup)
|
||||
self.assertEqual(
|
||||
sorted(name for name, _ in parse(markup).attrs), ["title"], markup,
|
||||
)
|
||||
|
||||
def test_a_normal_date_still_renders_its_short_form(self):
|
||||
markup = self._markup("2024-01-02 03:04:05")
|
||||
self.assertIn(">2024-01-02<", markup)
|
||||
self.assertEqual([t for t in parse(markup).tags], ["span"])
|
||||
|
||||
def test_a_negative_timestamp_is_still_na(self):
|
||||
self.assertEqual(self._markup(-1), "N/A")
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class ChannelBannerTest(_InertAssertions):
|
||||
"""The "Channel: ..." banner, rendered through the shipped source line."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.line = line_containing(JS.text(MANAGER), CHANNEL_ANCHOR)
|
||||
|
||||
def _markup(self, channel):
|
||||
expression = self.line.split("innerHTML =", 1)[1].strip().rstrip(";")
|
||||
return run_node(
|
||||
"%s\nconst self = {channel: %s};\n"
|
||||
"console.log(JSON.stringify({markup: %s}));\n"
|
||||
% (_preamble(), json.dumps(channel),
|
||||
expression.replace("this.channel", "self.channel"))
|
||||
)["markup"]
|
||||
|
||||
def test_a_payload_in_the_channel_alias_renders_inert(self):
|
||||
markup = self._markup(TEXT_PAYLOAD)
|
||||
self.assert_inert(markup, allowed_tags=set())
|
||||
self.assertIn("Channel: ", markup)
|
||||
|
||||
def test_a_benign_alias_is_still_readable(self):
|
||||
# No server transform touches this value, so escaping it once is
|
||||
# correct and cannot show entity text for an ordinary alias.
|
||||
self.assertIn("Channel: dev (Incomplete list)", self._markup("dev"))
|
||||
|
||||
|
||||
class ColumnShapeTest(unittest.TestCase):
|
||||
"""The formatters must keep EXISTING, or the grid's default takes over.
|
||||
|
||||
turbogrid's default formatter is a pass-through, so deleting either
|
||||
formatter silently restores the raw-value-into-innerHTML path these tests
|
||||
exist to close -- with every behavioural test above still passing, because
|
||||
they lift the formatter and would simply stop being able to find one.
|
||||
"""
|
||||
|
||||
def test_both_columns_declare_a_formatter(self):
|
||||
source = JS.text(MANAGER)
|
||||
for anchor in (STARS_ANCHOR, LAST_UPDATE_ANCHOR):
|
||||
with self.subTest(column=anchor):
|
||||
entry = slice_object_entry(source, anchor)
|
||||
self.assertIn(
|
||||
"formatter:", entry,
|
||||
"%s lost its formatter; the grid default renders the raw "
|
||||
"value into innerHTML" % (anchor,),
|
||||
)
|
||||
self.assertIn("sanitizeHTML(", entry)
|
||||
|
||||
|
||||
class PopulateProvenanceTest(unittest.TestCase):
|
||||
"""WHY escaping here is single-escaping, not double-escaping.
|
||||
|
||||
If the server ever started escaping these two fields, the formatters would
|
||||
render entity text to the user and this file would be the thing to change.
|
||||
The premise is therefore asserted rather than assumed.
|
||||
"""
|
||||
|
||||
def test_populate_markdown_does_not_cover_stars_or_last_update(self):
|
||||
source = (GLOB_DIR / "manager_server.py").read_text(encoding="utf-8")
|
||||
body = source[source.index("def populate_markdown("):]
|
||||
body = body[:body.index("\ndef ", 1)] if "\ndef " in body[1:] else body
|
||||
for field in ("'description'", "'name'", "'title'"):
|
||||
self.assertIn("if %s in x:" % field, body)
|
||||
for field in ("stars", "last_update"):
|
||||
self.assertNotIn(
|
||||
"'%s'" % field, body,
|
||||
"populate_markdown now touches %s -- if it escapes it, the "
|
||||
"formatter must STOP escaping or the user reads entity text"
|
||||
% (field,),
|
||||
)
|
||||
|
||||
def test_the_stats_fields_are_written_untransformed(self):
|
||||
source = (GLOB_DIR / "manager_core.py").read_text(encoding="utf-8")
|
||||
body = source[source.index("def populate_github_stats("):]
|
||||
body = body[:body.index("\ndef ", 1)]
|
||||
self.assertIn("v['stars'] = json_obj_github[url]['stars']", body)
|
||||
self.assertIn("v['last_update'] = json_obj_github[url]['last_update']", body)
|
||||
self.assertIn(
|
||||
"except:", body,
|
||||
"the bare except is what leaves a channel-supplied stars/last_update "
|
||||
"in place when reference lookup raises; if it is gone, re-derive the "
|
||||
"provenance argument in this file's docstring",
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class PackOperationErrorTest(_InertAssertions):
|
||||
def test_failed_operations_keep_server_escaped_titles_readable(self):
|
||||
populate = load_markdown_functions(load_manager_util())["populate_markdown"]
|
||||
methods = ",\n".join(slice_braced(JS.text(MANAGER), "\n\t" + marker) for marker in [
|
||||
"async installNodes(list, btn, title, selected_version)",
|
||||
"showError(err)", "showMessage(msg, color)", "showStatus(msg, color)",
|
||||
])
|
||||
for title in ("Pack <Flux> & friends", TEXT_PAYLOAD):
|
||||
item = {"title": title, "originalData": {}, "hash": "pack"}
|
||||
populate(item)
|
||||
for mode in ("install", "uninstall"):
|
||||
for status in (403, 500):
|
||||
with self.subTest(title=title, mode=mode, status=status):
|
||||
out = run_node("""
|
||||
%s
|
||||
const item = %s;
|
||||
const elements = {};
|
||||
const messages = {};
|
||||
const api = {fetchApi: async () => ({status: %d,
|
||||
json: async () => ({is_processing: false}), text: async () => %s})};
|
||||
const show_message = message => messages.dialog = message;
|
||||
const customConfirm = async () => true;
|
||||
const ctx = {
|
||||
%s,
|
||||
element: {querySelector: selector => elements[selector] ||= {innerHTML: ''}},
|
||||
grid: {getRowItemBy: () => item, scrollRowIntoView() {}, updateCell() {}},
|
||||
focusInstall() {return true;}
|
||||
};
|
||||
await ctx.installNodes(['pack'], {target: {classList: {add() {}}},
|
||||
label: 'Operation', mode: %s}, '', null);
|
||||
messages.panel = elements['.cn-manager-message'].innerHTML;
|
||||
console.log(JSON.stringify(messages));
|
||||
""" % (_preamble(), json.dumps(item), status, json.dumps(TEXT_PAYLOAD), methods, json.dumps(mode)))
|
||||
for message in out.values():
|
||||
self.assert_inert(message, allowed_tags={"font"})
|
||||
self.assertIn(f"'{title}': ", html.unescape(message))
|
||||
if status == 500:
|
||||
self.assertIn(": " + TEXT_PAYLOAD, html.unescape(message))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,590 @@
|
||||
"""Check remote notice HTML, sharing URLs, snapshot names and node selection UI.
|
||||
|
||||
Execute shipped Python and JavaScript with isolated external boundaries. Rendering
|
||||
checks cover hostile inputs and readable controls; link checks preserve opener isolation.
|
||||
"""
|
||||
import ast
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
from html import unescape
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
from js_lift import NODE, JsSource, run_node
|
||||
from manager_test_utils import GLOB_DIR, load_manager_util, load_markdown_functions
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
|
||||
|
||||
COMMON = "common.js"
|
||||
SHARE_COMMON = "comfyui-share-common.js"
|
||||
SHARE_OPENART = "comfyui-share-openart.js"
|
||||
SHARE_COPUS = "comfyui-share-copus.js"
|
||||
SHARE_YOUML = "comfyui-share-youml.js"
|
||||
SNAPSHOT = "snapshot.js"
|
||||
MANAGER = "custom-nodes-manager.js"
|
||||
|
||||
# A scheme that executes on click, and a scheme that carries its own document.
|
||||
# Both are strings a third party can put in a URL field; neither is a host.
|
||||
SCHEME_PAYLOAD = "javascript:window.__probe=1"
|
||||
DATA_PAYLOAD = "data:text/html,<b>x</b>"
|
||||
BENIGN_URL = "https://example.com/w/123?a=1&b=2"
|
||||
# An http URL that ALSO closes the attribute it lands in. A service-chosen URL
|
||||
# is not "either a bad scheme or a quote" -- it is one string that can be both,
|
||||
# so a scheme allow-list alone leaves the quote. One variant per quoting style,
|
||||
# because the call sites differ.
|
||||
BREAKOUT_SINGLE_QUOTED = "https://example.com/x' onmouseover='window.__probe=1"
|
||||
BREAKOUT_DOUBLE_QUOTED = 'https://example.com/x" onmouseover="window.__probe=1'
|
||||
# Element text, for the sinks that render a name rather than a link.
|
||||
TEXT_PAYLOAD = '<img src=x onerror="window.__probe=1">'
|
||||
|
||||
|
||||
def _function_source(name: str, filename: str = "manager_server.py") -> str:
|
||||
"""The source text of one top-level function, WITHOUT importing its module.
|
||||
|
||||
Used for the wiring legs, where the question is "does this call site pass
|
||||
its value through the normaliser" -- a question about the call site, which a
|
||||
behavioural test on the normaliser alone cannot answer.
|
||||
"""
|
||||
source = (GLOB_DIR / filename).read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
wanted = [
|
||||
node for node in tree.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name
|
||||
]
|
||||
assert len(wanted) == 1, "expected exactly one %s, got %d" % (name, len(wanted))
|
||||
return ast.get_source_segment(source, wanted[0]) or ""
|
||||
|
||||
|
||||
class _Collector(HTMLParser):
|
||||
"""What a browser would actually build from a fragment."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.elements = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.elements.append((tag, dict(attrs)))
|
||||
|
||||
handle_startendtag = handle_starttag
|
||||
|
||||
|
||||
def parse(markup: str) -> _Collector:
|
||||
collector = _Collector()
|
||||
collector.feed(markup)
|
||||
collector.close()
|
||||
return collector
|
||||
|
||||
|
||||
def tags(markup: str):
|
||||
return [tag for tag, _ in parse(markup).elements]
|
||||
|
||||
|
||||
def handler_attrs(markup: str):
|
||||
return [
|
||||
"%s@%s" % (tag, name)
|
||||
for tag, attrs in parse(markup).elements
|
||||
for name in attrs
|
||||
if name.lower().startswith("on")
|
||||
]
|
||||
|
||||
|
||||
def hrefs(markup: str):
|
||||
return [
|
||||
attrs["href"] for tag, attrs in parse(markup).elements
|
||||
if tag == "a" and "href" in attrs
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D1] the relayed wiki fragment
|
||||
# ---------------------------------------------------------------------------
|
||||
class NoticeFragmentSanitizerTest(unittest.TestCase):
|
||||
"""Notice sanitization must remove executable content while preserving formatting."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.manager_util = load_manager_util()
|
||||
|
||||
def _sanitize(self, fragment: str) -> str:
|
||||
fn = getattr(self.manager_util, "sanitize_html_fragment", None)
|
||||
self.assertIsNotNone(
|
||||
fn,
|
||||
"glob/manager_util.py exposes no sanitize_html_fragment(fragment). "
|
||||
"The notice fragment is remote HTML the server relays verbatim and "
|
||||
"the client assigns to innerHTML by design, so the server is the "
|
||||
"only boundary that can constrain it.",
|
||||
)
|
||||
return fn(fragment)
|
||||
|
||||
def test_a_script_element_does_not_survive(self):
|
||||
out = self._sanitize('<p>before</p><script>window.__probe=1</script><p>after</p>')
|
||||
self.assertNotIn("script", tags(out), out)
|
||||
self.assertIn("p", tags(out), "ordinary markup was destroyed along with it: %r" % (out,))
|
||||
|
||||
def test_a_handler_attribute_does_not_survive(self):
|
||||
out = self._sanitize('<img src="https://example.com/i.png" onerror="window.__probe=1">')
|
||||
self.assertEqual(handler_attrs(out), [], out)
|
||||
|
||||
def test_a_handler_attribute_does_not_survive_on_any_element(self):
|
||||
# The allow-list is over ATTRIBUTES, not over one element that happened
|
||||
# to be tested: a deny-list keyed to <img> leaves <div onmouseover=...>.
|
||||
for markup in (
|
||||
'<div onmouseover="window.__probe=1">hover</div>',
|
||||
'<a href="https://example.com/" onclick="window.__probe=1">click</a>',
|
||||
'<b ONERROR="window.__probe=1">bold</b>',
|
||||
):
|
||||
with self.subTest(markup=markup):
|
||||
self.assertEqual(handler_attrs(self._sanitize(markup)), [], markup)
|
||||
|
||||
def test_an_executing_scheme_does_not_survive_in_an_href(self):
|
||||
for payload in (SCHEME_PAYLOAD, DATA_PAYLOAD):
|
||||
with self.subTest(payload=payload):
|
||||
out = self._sanitize('<a href="%s">click</a>' % (payload,))
|
||||
self.assertEqual(
|
||||
[h for h in hrefs(out) if h.startswith(("javascript:", "data:"))], [],
|
||||
"the scheme survived into an href: %r" % (out,),
|
||||
)
|
||||
|
||||
def test_ordinary_formatting_still_displays(self):
|
||||
"""The half a strip-everything sanitizer would fail.
|
||||
|
||||
The notice is formatted prose. If the fix returns text, the feature is
|
||||
gone and no other test in this file would say so.
|
||||
"""
|
||||
fragment = (
|
||||
'<p><b>bold</b> and <i>italic</i></p>'
|
||||
'<ul><li>one</li><li>two</li></ul>'
|
||||
'<a href="https://example.com/x">link</a>'
|
||||
'<img src="https://example.com/i.png">'
|
||||
)
|
||||
out = self._sanitize(fragment)
|
||||
for tag in ("p", "b", "i", "ul", "li", "a", "img"):
|
||||
with self.subTest(tag=tag):
|
||||
self.assertIn(tag, tags(out), "%r was dropped: %r" % (tag, out))
|
||||
self.assertIn("https://example.com/x", hrefs(out), out)
|
||||
self.assertIn("one", out)
|
||||
self.assertIn("two", out)
|
||||
|
||||
def test_an_unclosed_element_does_not_blank_the_rest_of_the_notice(self):
|
||||
"""Malformed elements must not discard unrelated notice content."""
|
||||
for wrapper in ("div", "blockquote", "section", "article", "figure",
|
||||
"main", "header", "nav", "aside"):
|
||||
with self.subTest(wrapper=wrapper):
|
||||
out = self._sanitize(
|
||||
"<%s><svg></%s><p>after</p>" % (wrapper, wrapper))
|
||||
self.assertIn(
|
||||
"after", out,
|
||||
"an unclosed element inside <%s> swallowed the rest of the "
|
||||
"notice: %r" % (wrapper, out))
|
||||
self.assertNotIn("svg", tags(out), out)
|
||||
|
||||
def test_an_unterminated_script_still_fails_closed(self):
|
||||
"""HTML script/style contents must not be emitted as notice markup."""
|
||||
for fragment in ("<section><script></section><p>after</p>",
|
||||
"<div><style></div><p>after</p>"):
|
||||
with self.subTest(fragment=fragment):
|
||||
out = self._sanitize(fragment)
|
||||
# NOT `out == ""`: a wrapper's OPEN tag is emitted before the
|
||||
# skip window starts, so `<div><style>...` legitimately keeps
|
||||
# its `<div>`. The property is that nothing AFTER the
|
||||
# unterminated element comes back -- as markup or as text.
|
||||
self.assertNotIn(
|
||||
"after", out,
|
||||
"text after an unterminated script/style came back: %r"
|
||||
% (out,))
|
||||
self.assertEqual(
|
||||
[t for t in tags(out) if t != "div"], [],
|
||||
"markup after an unterminated script/style came back: %r"
|
||||
% (out,))
|
||||
|
||||
def test_foreign_content_does_not_hide_safe_following_prose(self):
|
||||
# HTML5 parsing closes the SVG context before this paragraph. The old
|
||||
# HTMLParser-based sanitizer treated the rest as script text and lost it.
|
||||
out = self._sanitize('<svg><script></svg><p>after</p>')
|
||||
self.assertEqual(tags(out), ['p'])
|
||||
self.assertIn('after', out)
|
||||
|
||||
def test_notice_formatting_attributes_survive(self):
|
||||
out = self._sanitize(
|
||||
'<p class="notice" title="Release" align="center">'
|
||||
'<font color="red">Update</font></p>'
|
||||
'<table width="400"><tr><td colspan="2" rowspan="3">Data</td></tr></table>'
|
||||
'<img src="https://example.com/i.png" alt="Preview" width="100" height="80">'
|
||||
)
|
||||
elements = dict(parse(out).elements)
|
||||
self.assertEqual(elements['p'], {'class': 'notice', 'title': 'Release', 'align': 'center'})
|
||||
self.assertEqual(elements['font'], {'color': 'red'})
|
||||
self.assertEqual(elements['table']['width'], '400')
|
||||
self.assertEqual(elements['td'], {'colspan': '2', 'rowspan': '3'})
|
||||
self.assertEqual(elements['img'], {
|
||||
'src': 'https://example.com/i.png', 'alt': 'Preview', 'width': '100', 'height': '80',
|
||||
})
|
||||
|
||||
def test_notice_links_open_safely_regardless_of_input_attribute_order(self):
|
||||
for attrs in (
|
||||
'href="https://example.com/?a=1&b=2" target="named" rel="opener"',
|
||||
'title="Docs" rel="opener" target="named" href="https://example.com/?a=1&b=2"',
|
||||
):
|
||||
with self.subTest(attrs=attrs):
|
||||
out = self._sanitize(f'<a {attrs}>Docs</a>')
|
||||
anchor = dict(parse(out).elements)['a']
|
||||
self.assertEqual(anchor['href'], 'https://example.com/?a=1&b=2')
|
||||
self.assertEqual(anchor['target'], '_blank')
|
||||
self.assertEqual(set(anchor['rel'].split()), {'noopener', 'noreferrer'})
|
||||
|
||||
def test_notice_url_schemes_remain_limited_to_http_https_and_relative(self):
|
||||
for url in ('ftp://example.com', 'mailto:a@example.com', 'java	script:alert`1`'):
|
||||
with self.subTest(url=url):
|
||||
out = self._sanitize(f'<a href="{url}">Link</a><img src="{url}">')
|
||||
for _, attrs in parse(out).elements:
|
||||
self.assertNotIn('href', attrs)
|
||||
self.assertNotIn('src', attrs)
|
||||
for url in ('http://example.com', 'https://example.com', '/relative', '#section'):
|
||||
with self.subTest(url=url):
|
||||
self.assertEqual(hrefs(self._sanitize(f'<a href="{url}">Link</a>')), [url])
|
||||
|
||||
def test_hidden_element_contents_are_not_exposed_as_notice_text(self):
|
||||
for tag in ('script', 'style', 'iframe', 'object', 'svg', 'math',
|
||||
'template', 'noscript', 'textarea', 'title'):
|
||||
with self.subTest(tag=tag):
|
||||
out = self._sanitize(f'<{tag}>hidden</{tag}><p>after</p>')
|
||||
self.assertNotIn('hidden', out)
|
||||
self.assertIn('after', out)
|
||||
|
||||
def test_a_mixed_fragment_keeps_the_good_and_drops_the_bad(self):
|
||||
"""One fragment carrying both, since a real wiki page would."""
|
||||
out = self._sanitize(
|
||||
'<p>Release <b>1.2</b></p>'
|
||||
'<script>window.__probe=1</script>'
|
||||
'<a href="https://example.com/notes">notes</a>'
|
||||
'<a href="%s">click</a>'
|
||||
'<img src="https://example.com/i.png" onerror="window.__probe=1">'
|
||||
% (SCHEME_PAYLOAD,)
|
||||
)
|
||||
self.assertNotIn("script", tags(out), out)
|
||||
self.assertEqual(handler_attrs(out), [], out)
|
||||
self.assertEqual(
|
||||
[h for h in hrefs(out) if h.startswith(("javascript:", "data:"))], [], out)
|
||||
self.assertIn("https://example.com/notes", hrefs(out), out)
|
||||
self.assertIn("b", tags(out), out)
|
||||
self.assertIn("img", tags(out), "the benign image was dropped: %r" % (out,))
|
||||
|
||||
|
||||
class NoticeWiringTest(unittest.TestCase):
|
||||
"""Check the notice route applies the sanitizer to its captured remote HTML."""
|
||||
|
||||
def test_get_notice_passes_the_captured_fragment_through_the_sanitizer(self):
|
||||
"""Verify the captured HTML is sanitized, not merely that the function is mentioned."""
|
||||
body = _function_source("get_notice")
|
||||
self.assertIn(
|
||||
"match.group(1)", body,
|
||||
"get_notice no longer captures the fragment the way this guard "
|
||||
"expects; re-derive it against the new shape rather than deleting "
|
||||
"the assertion.",
|
||||
)
|
||||
self.assertRegex(
|
||||
body, r"sanitize_html_fragment\s*\(\s*match\.group\(1\)\s*\)",
|
||||
"get_notice relays the captured wiki fragment without passing it "
|
||||
"through an allow-list normaliser. The client assigns the response "
|
||||
"body to innerHTML, so whatever the wiki page contains is what the "
|
||||
"browser builds. The call has to take the capture as its argument; "
|
||||
"naming the function elsewhere in the body is not wiring.",
|
||||
)
|
||||
self.assertNotRegex(
|
||||
body, re.compile(r"^\s*markdown_content\s*=\s*match\.group\(1\)\s*$",
|
||||
re.MULTILINE),
|
||||
"the raw capture is still assigned directly; the sanitizer must sit "
|
||||
"on this assignment, not beside it.",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D2] share-service URLs
|
||||
# ---------------------------------------------------------------------------
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class ShareDialogUrlTest(unittest.TestCase):
|
||||
"""Share-service URLs must use safe schemes and quoted attributes."""
|
||||
|
||||
def _preamble(self) -> str:
|
||||
span = JS.lift_span(
|
||||
COMMON, "export const SAFE_URL_SCHEMES",
|
||||
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
|
||||
return "\n".join([
|
||||
JS.lift_declaration(COMMON, "export function sanitizeHTML("),
|
||||
span.replace("export ", ""),
|
||||
])
|
||||
|
||||
def _render(self, expression: str, bindings: str) -> str:
|
||||
return run_node(
|
||||
"%s\n%s\nconsole.log(JSON.stringify({markup: %s}));\n"
|
||||
% (self._preamble(), bindings, expression)
|
||||
)["markup"]
|
||||
|
||||
def _assert_scheme_rejected(self, markup: str, label: str):
|
||||
bad = [h for h in hrefs(markup) if h.startswith(("javascript:", "data:"))]
|
||||
self.assertEqual(
|
||||
bad, [],
|
||||
"%s emits a service-chosen scheme as a clickable href: %r" % (label, markup),
|
||||
)
|
||||
self.assertEqual(handler_attrs(markup), [], markup)
|
||||
|
||||
# --- comfyui-share-common.js, the fully service-controlled URL ----------
|
||||
def _common_expression(self) -> str:
|
||||
from js_lift import line_containing
|
||||
line = line_containing(JS.text(SHARE_COMMON),
|
||||
'this.final_message.innerHTML = "Your art has been shared:')
|
||||
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
|
||||
|
||||
def test_share_common_rejects_an_executing_scheme(self):
|
||||
# The href here is SINGLE-quoted, so the breakout variant uses `'`.
|
||||
for payload in (SCHEME_PAYLOAD, DATA_PAYLOAD, BREAKOUT_SINGLE_QUOTED):
|
||||
with self.subTest(payload=payload):
|
||||
markup = self._render(
|
||||
self._common_expression(),
|
||||
"const response_json = {comfyworkflows: {url: %s}};" % (_json(payload),))
|
||||
self._assert_scheme_rejected(markup, "comfyui-share-common.js")
|
||||
|
||||
def test_share_common_still_links_an_ordinary_url(self):
|
||||
"""The leg a refuse-everything fix would fail."""
|
||||
markup = self._render(
|
||||
self._common_expression(),
|
||||
"const response_json = {comfyworkflows: {url: %s}};" % (_json(BENIGN_URL),))
|
||||
self.assertIn(
|
||||
BENIGN_URL, hrefs(markup),
|
||||
"an ordinary https URL must still become a working link: %r" % (markup,))
|
||||
|
||||
def test_every_share_anchor_severs_the_opener(self):
|
||||
"""Check opener isolation without imposing an order on rel tokens."""
|
||||
cases = [
|
||||
("comfyui-share-common.js", self._common_expression(),
|
||||
"const response_json = {comfyworkflows: {url: %s}};" % (_json(BENIGN_URL),)),
|
||||
("comfyui-share-youml.js", self._youml_expression(),
|
||||
"const messagePrefix = 'shared.';\nconst recipePageUrl = %s;" % (_json(BENIGN_URL),)),
|
||||
("comfyui-share-openart.js", self._openart_expression(),
|
||||
"const url = %s;" % (_json(BENIGN_URL),)),
|
||||
("comfyui-share-copus.js", self._copus_expression(),
|
||||
"const url = %s;" % (_json(BENIGN_URL),)),
|
||||
]
|
||||
for name, expression, bindings in cases:
|
||||
with self.subTest(file=name):
|
||||
markup = self._render(expression, bindings)
|
||||
anchors = [attrs for tag, attrs in parse(markup).elements if tag == "a"]
|
||||
self.assertTrue(anchors, "no anchor produced by %s: %r" % (name, markup))
|
||||
for attrs in anchors:
|
||||
self.assertEqual(
|
||||
attrs.get("target"), "_blank",
|
||||
"%s: this guard is about _blank anchors; the target "
|
||||
"changed, so re-derive it: %r" % (name, markup))
|
||||
self.assertIn(
|
||||
"rel", attrs,
|
||||
"%s opens a service-chosen page in a new tab without "
|
||||
"rel, so that page keeps a reference to this window: %r"
|
||||
% (name, markup))
|
||||
rel = attrs["rel"].lower().split()
|
||||
self.assertIn("noopener", rel, markup)
|
||||
self.assertIn("noreferrer", rel, markup)
|
||||
|
||||
# --- comfyui-share-youml.js, also fully service-controlled --------------
|
||||
def _youml_expression(self) -> str:
|
||||
span = JS.lift_span(SHARE_YOUML, "this.message.innerHTML = `${messagePrefix}",
|
||||
"visit it on YouML</a>`;")
|
||||
return span.split("innerHTML =", 1)[1].strip().rstrip(";")
|
||||
|
||||
def test_share_youml_rejects_an_executing_scheme(self):
|
||||
# The href here is DOUBLE-quoted, so the breakout variant uses `"`.
|
||||
for payload in (SCHEME_PAYLOAD, DATA_PAYLOAD, BREAKOUT_DOUBLE_QUOTED):
|
||||
with self.subTest(payload=payload):
|
||||
markup = self._render(
|
||||
self._youml_expression(),
|
||||
"const messagePrefix = 'Workflow has been shared.';\n"
|
||||
"const recipePageUrl = %s;" % (_json(payload),))
|
||||
self._assert_scheme_rejected(markup, "comfyui-share-youml.js")
|
||||
|
||||
# --- openart / copus: an id is interpolated into a URL PATH -------------
|
||||
# The scheme is hardcoded, so these cannot carry javascript:. What they CAN
|
||||
# carry is a quote: the id is a service-returned string dropped inside a
|
||||
# double-quoted attribute, so `"` closes it and the rest is markup. That is a
|
||||
# different failure from the two above and is asserted as such.
|
||||
def _openart_expression(self) -> str:
|
||||
from js_lift import line_containing
|
||||
line = line_containing(JS.text(SHARE_OPENART),
|
||||
"this.message.innerHTML = `Workflow has been shared successfully.")
|
||||
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
|
||||
|
||||
def _copus_expression(self) -> str:
|
||||
from js_lift import line_containing
|
||||
line = line_containing(JS.text(SHARE_COPUS),
|
||||
"this.message.innerHTML = `Workflow has been shared successfully.")
|
||||
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
|
||||
|
||||
def test_openart_id_cannot_break_out_of_the_href_attribute(self):
|
||||
markup = self._render(
|
||||
self._openart_expression(),
|
||||
'const url = "https://openart.ai/workflows/-/-/" + '
|
||||
'%s;' % (_json('x" onmouseover="window.__probe=1'),))
|
||||
self.assertEqual(handler_attrs(markup), [], markup)
|
||||
|
||||
def test_copus_id_cannot_break_out_of_the_href_attribute(self):
|
||||
markup = self._render(
|
||||
self._copus_expression(),
|
||||
'const url = "https://example.invalid/work/" + '
|
||||
'%s;' % (_json('x" onmouseover="window.__probe=1'),))
|
||||
self.assertEqual(handler_attrs(markup), [], markup)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D3] the snapshot name
|
||||
# ---------------------------------------------------------------------------
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class SnapshotNameTest(unittest.TestCase):
|
||||
"""Snapshot filenames must remain inert text in status messages."""
|
||||
|
||||
def _render(self, name: str) -> str:
|
||||
line = _unique_line(JS.text(SNAPSHOT), "self.updateMessage(`<BR><font color=")
|
||||
expression = _call_argument(line, "self.updateMessage(")
|
||||
return run_node(
|
||||
"%s\nconst target = {name: %s};\n"
|
||||
"console.log(JSON.stringify({markup: %s}));\n"
|
||||
% (JS.lift_declaration(COMMON, "export function sanitizeHTML("),
|
||||
_json(name), expression)
|
||||
)["markup"]
|
||||
|
||||
def test_a_crafted_snapshot_name_renders_as_text(self):
|
||||
markup = self._render(TEXT_PAYLOAD)
|
||||
self.assertEqual(handler_attrs(markup), [], markup)
|
||||
self.assertNotIn("img", tags(markup), markup)
|
||||
|
||||
def test_an_ordinary_snapshot_name_still_reads_normally(self):
|
||||
markup = self._render("2026-09-07_backup")
|
||||
self.assertIn("2026-09-07_backup", markup)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D4] rel=noopener, and the defensive escapes
|
||||
# ---------------------------------------------------------------------------
|
||||
class ServerAnchorRelTest(unittest.TestCase):
|
||||
"""Converted [a/text](url) links must isolate the opener and remain usable."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.manager_util = load_manager_util()
|
||||
# staticmethod, or attribute access binds it and passes `self`
|
||||
# as the markdown argument.
|
||||
cls.convert = staticmethod(load_markdown_functions(cls.manager_util)["convert_markdown_to_html"])
|
||||
|
||||
def test_a_markdown_link_carries_rel_noopener_noreferrer(self):
|
||||
out = self.convert("see [a/the docs](https://example.com/docs)")
|
||||
anchors = [attrs for tag, attrs in parse(out).elements if tag == "a"]
|
||||
self.assertTrue(anchors, "no anchor was produced at all: %r" % (out,))
|
||||
for attrs in anchors:
|
||||
with self.subTest(attrs=attrs):
|
||||
self.assertIn(
|
||||
"rel", attrs,
|
||||
"the anchor opens a new tab without rel: %r. The opened page "
|
||||
"keeps a reference to this window." % (out,),
|
||||
)
|
||||
rel = attrs["rel"].lower().split()
|
||||
self.assertIn("noopener", rel, out)
|
||||
self.assertIn("noreferrer", rel, out)
|
||||
|
||||
def test_the_link_still_works(self):
|
||||
out = self.convert("see [a/the docs](https://example.com/docs)")
|
||||
self.assertIn("https://example.com/docs", hrefs(out), out)
|
||||
|
||||
|
||||
class ClientAnchorAndStateTest(unittest.TestCase):
|
||||
"""External-link isolation and rendering of unrecognised pack states."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.source = JS.text(MANAGER)
|
||||
|
||||
def test_the_title_link_severs_the_opener(self):
|
||||
marker = "link.target = '_blank';"
|
||||
index = _unique_index(self.source, marker)
|
||||
# max(0, ...) because a negative start would wrap to the END of the
|
||||
# string and silently hand back an unrelated window.
|
||||
window = self.source[max(0, index - 400):index + 400]
|
||||
self.assertRegex(
|
||||
window, r"link\.rel\s*=",
|
||||
"the title link opens a new tab without setting rel, so the pack's "
|
||||
"own repository page keeps a reference to this window.",
|
||||
)
|
||||
rel = re.search(r"link\.rel\s*=\s*['\"]([^'\"]*)['\"]", window)
|
||||
self.assertIsNotNone(rel, "link.rel is set from a non-literal: %r" % (window,))
|
||||
tokens = rel.group(1).lower().split()
|
||||
# Membership, not token order: 'noreferrer noopener' is the same thing,
|
||||
# and demanding one spelling would false-RED a correct fix. Matches how
|
||||
# ServerAnchorRelTest checks the server-side anchor.
|
||||
self.assertIn("noopener", tokens, window)
|
||||
self.assertIn("noreferrer", tokens, window)
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required")
|
||||
def test_selection_survives_unrecognised_and_prototype_names(self):
|
||||
sanitize = JS.lift_declaration(COMMON, "export function sanitizeHTML(")
|
||||
buttons = JS.lift_declaration(MANAGER, "\n\tgetActionButtons(")
|
||||
render = JS.lift_declaration(MANAGER, "\n\trenderSelected()")
|
||||
for state in ['not-a-known-install-group', 'constructor', '__proto__', TEXT_PAYLOAD, 'enabled']:
|
||||
with self.subTest(state=state):
|
||||
result = run_node(sanitize + "\nconst manager = {" + buttons + "," + render + """,
|
||||
grid: {hasMask: false, getSelectedRows: () => [{action: STATE, hash: 'fixture'}]},
|
||||
getFilterItem: () => null,
|
||||
showSelection(html) { this.html = html; }
|
||||
};
|
||||
manager.renderSelected();
|
||||
console.log(JSON.stringify({html: manager.html,
|
||||
buttons: manager.getActionButtons(STATE, {version: 'unknown'}, true)}));
|
||||
""".replace('STATE', _json(state)))
|
||||
self.assertIn('Selected <b>1</b>', result['html'])
|
||||
self.assertIn(state, unescape(result['html']))
|
||||
self.assertFalse(handler_attrs(result['html']))
|
||||
self.assertNotIn('img', tags(result['html']))
|
||||
if state == 'enabled':
|
||||
self.assertIn('<button', result['buttons'])
|
||||
else:
|
||||
self.assertEqual(result['buttons'], '')
|
||||
|
||||
|
||||
def _json(value) -> str:
|
||||
import json
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
def _unique_index(source: str, marker: str) -> int:
|
||||
"""Index of `marker`, refusing to guess when it is not unique.
|
||||
|
||||
js_lift.line_containing takes the FIRST occurrence silently. Every marker
|
||||
here is unique today; this makes a future second occurrence fail loudly
|
||||
instead of pinning whichever copy happens to come first.
|
||||
"""
|
||||
count = source.count(marker)
|
||||
assert count == 1, "marker %r occurs %d times; disambiguate it" % (marker, count)
|
||||
return source.index(marker)
|
||||
|
||||
|
||||
def _unique_line(source: str, marker: str) -> str:
|
||||
"""The single source line holding `marker`, with the same uniqueness rule."""
|
||||
index = _unique_index(source, marker)
|
||||
start = source.rfind("\n", 0, index) + 1
|
||||
return source[start:source.index("\n", index)]
|
||||
|
||||
|
||||
def _call_argument(line: str, call_marker: str) -> str:
|
||||
"""The argument text of `call_marker(...)`, dropping exactly one `)` and `;`.
|
||||
|
||||
NOT `.rstrip(");")`: rstrip takes a CHARACTER SET, so it eats every trailing
|
||||
`)`, `;` and `"`. A fix spelled `updateMessage(escape(`...`));` would lose a
|
||||
closing paren it needs and become a node syntax error -- which pytest would
|
||||
show as this guard failing, i.e. the fix would look like the defect.
|
||||
"""
|
||||
text = line.split(call_marker, 1)[1].rstrip()
|
||||
if text.endswith(";"):
|
||||
text = text[:-1].rstrip()
|
||||
assert text.endswith(")"), "unexpected call shape: %r" % (line.strip(),)
|
||||
return text[:-1]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Render actual update-completion handlers in an isolated browser."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from js_lift import JsSource
|
||||
|
||||
JS = JsSource(Path(__file__).resolve().parent.parent / "js")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["success", "failed"])
|
||||
def test_update_results_preserve_links_titles_and_missing_url_labels(status):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
helpers = JS.lift_declaration("common.js", "export function sanitizeHTML(")
|
||||
helpers += JS.lift_span(
|
||||
"common.js", "export const SAFE_URL_SCHEMES",
|
||||
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));",
|
||||
).replace("export ", "")
|
||||
helpers += JS.lift_declaration("common.js", "export function show_message(")
|
||||
handler = JS.lift_declaration("comfyui-manager.js", "async function onQueueStatus(")
|
||||
url = "https://example.test/guide?q=O'Reilly&lang=en"
|
||||
title = "Guide <draft> & notes"
|
||||
fallback_id = "Pack <draft>"
|
||||
|
||||
with playwright.sync_playwright() as driver:
|
||||
if not Path(driver.chromium.executable_path).exists():
|
||||
pytest.skip("install Chromium with: playwright install chromium")
|
||||
browser = driver.chromium.launch()
|
||||
page = browser.new_page()
|
||||
page.route("**/*", lambda route: route.abort())
|
||||
page.set_content('<div id="dialog"></div>')
|
||||
page.add_script_tag(content="""
|
||||
let is_updating = true;
|
||||
function reset_action_buttons() {}
|
||||
function setNeedRestart() {}
|
||||
function infoToast() {}
|
||||
const app = {ui: {dialog: {
|
||||
element: document.getElementById('dialog'),
|
||||
show(message) { this.element.innerHTML = message; }
|
||||
}}};
|
||||
""" + helpers + handler)
|
||||
page.evaluate("event => onQueueStatus(event)", {"detail": {
|
||||
"status": "done", "nodepack_result": {
|
||||
"comfyui": "skip",
|
||||
"pack-link": {"url": url, "title": title, "msg": status},
|
||||
fallback_id: {"msg": status},
|
||||
},
|
||||
}})
|
||||
items = page.locator("#dialog li")
|
||||
assert items.all_text_contents() == [title, fallback_id]
|
||||
assert items.nth(0).locator("a").get_attribute("href") == url
|
||||
assert set(items.nth(0).locator("a").evaluate("node => node.getAttributeNames()")) == {
|
||||
"href", "target", "rel",
|
||||
}
|
||||
assert items.nth(0).locator("a").get_attribute("rel") == "noopener noreferrer"
|
||||
assert items.nth(1).locator("a").count() == 0
|
||||
assert page.locator("#dialog draft").count() == 0
|
||||
browser.close()
|
||||
@@ -0,0 +1,410 @@
|
||||
"""Drift guard: the JS sanitizeUrl must agree with glob/manager_util.py::sanitize_url.
|
||||
|
||||
There are two implementations of one URL allow-list — ``sanitizeUrl`` in
|
||||
js/common.js and ``sanitize_url`` in glob/manager_util.py. They agree today.
|
||||
Nothing structural keeps them agreeing: a change to either side has no failing
|
||||
test on the other, and the JS side is what stands between channel data and an
|
||||
href. This module is that missing coupling. It runs BOTH real implementations
|
||||
over a shared case list and fails on any divergence, naming the input and both
|
||||
answers.
|
||||
|
||||
WHAT IT COMPARES: the real shipped code on both sides. The Python function is
|
||||
loaded by file location (``glob/`` has no ``__init__.py``, and putting it on
|
||||
sys.path would shadow the stdlib ``glob``); the JS function is lifted out of
|
||||
js/common.js by tests/js_lift and executed in node. Neither is a copy, so a
|
||||
perturbation of either implementation turns this guard RED.
|
||||
|
||||
CASES: the shared matrix at ``cases/url_sanitize_cases.json`` (57 inputs handed
|
||||
over by gm3-review from the WI-112 and WI-122 reviews, since extended) PLUS a
|
||||
generated grid that composes scheme x in-scheme-noise x delimiter. The generated
|
||||
half matters because a fixed list can only catch divergence it happens to
|
||||
contain, and the implementations are what is under test, not the list.
|
||||
|
||||
TWO AXES ARE COMPARED, not one. Behaviour over sampled inputs cannot see a
|
||||
difference no sample happens to hold, so the two pieces of CONFIGURATION the
|
||||
implementations branch on are compared directly as well: the scheme allow-list
|
||||
(``test_the_scheme_allow_lists_are_identical``) and the whitespace set each side
|
||||
trims off an accepted URL (``test_the_trim_sets_are_identical``). The trim axis
|
||||
was added after the matrix was found to contain no BOM or unicode-whitespace
|
||||
input at all — ``String.prototype.trim()`` and ``str.strip()`` disagree on six
|
||||
codepoints (U+FEFF one way; U+001C-001F and U+0085 the other), and every case in
|
||||
the matrix had ASCII edges, so the divergence sat unguarded. js/common.js now
|
||||
spells Python's set out rather than delegating to ``trim()``; see the
|
||||
``unicode_whitespace_divergence`` block in the case file.
|
||||
|
||||
⛔ STRING INPUTS ONLY, deliberately. Python ``str()`` and JS ``String()`` are
|
||||
not equivalent for non-strings — 1.0 -> '1.0' vs '1', True -> 'True' vs 'true',
|
||||
[1,2] -> '[1, 2]' vs '1,2', {} -> '{}' vs '[object Object]' — so feeding
|
||||
non-strings would report divergence for something that is not a defect. In
|
||||
practice ``url`` arrives as a string off the JSON payload. The two aligned
|
||||
non-string cases that DO matter are pinned separately in
|
||||
``test_null_and_undefined_agree`` rather than left as an undocumented gap; the
|
||||
right response to a coercion mismatch is to narrow the domain, never to loosen
|
||||
the comparison, because a loosened comparison stops catching real drift.
|
||||
"""
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from js_lift import NODE, JsSource, run_node
|
||||
from manager_test_utils import load_manager_util
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
|
||||
CASES_PATH = Path(__file__).resolve().parent / "cases" / "url_sanitize_cases.json"
|
||||
|
||||
# Every codepoint ``str.strip()`` removes, measured by RUNNING it rather than by
|
||||
# restating a table. sanitize_url returns ``raw.strip()``, so this is the set the
|
||||
# JS side has to match; test_the_trim_sets_are_identical asserts that linkage
|
||||
# still holds before comparing.
|
||||
PYTHON_TRIM_SET = frozenset(c for c in range(0x110000) if chr(c).strip() == "")
|
||||
|
||||
|
||||
def _generated_cases(allow_listed):
|
||||
"""Compose scheme x in-scheme noise x delimiter into extra inputs.
|
||||
|
||||
A fixed list only catches the divergence it already contains. This grid is
|
||||
derived from the axes the two implementations actually branch on — the
|
||||
scheme allow-list, the control-char strip, and the head-delimiter cut — so
|
||||
it explores combinations nobody wrote down.
|
||||
|
||||
``allow_listed`` is the UNION of the two live allow-lists, not a literal, so
|
||||
a scheme added to either side is exercised behaviourally without anyone
|
||||
remembering to add a case for it. That is a second layer under
|
||||
test_the_scheme_allow_lists_are_identical, which is what catches an
|
||||
allow-list divergence definitively; this layer only makes the behavioural
|
||||
sampling track the configuration instead of drifting away from it.
|
||||
"""
|
||||
schemes = sorted(set(allow_listed) | {
|
||||
"HTTP", "javascript", "data", "vbscript", "file", "a-b", "a.b",
|
||||
})
|
||||
noises = ["", "\t", "\n", "\x00", "\x01", " ", "\x7f"]
|
||||
delimiters = ["", "/", "?", "#", "//host/p", "?q=1", "#frag"]
|
||||
for scheme in schemes:
|
||||
for noise in noises:
|
||||
head = scheme[:2] + noise + scheme[2:]
|
||||
for delimiter in delimiters:
|
||||
yield "%s:%s" % (head, delimiter)
|
||||
yield " %s:%s" % (head, delimiter)
|
||||
|
||||
|
||||
def _all_cases(allow_listed):
|
||||
matrix = json.loads(CASES_PATH.read_text(encoding="utf-8"))
|
||||
cases = list(matrix["shared_matrix"])
|
||||
cases.extend(_generated_cases(allow_listed))
|
||||
# De-duplicate while preserving order so a failure names a stable index.
|
||||
seen = set()
|
||||
ordered = []
|
||||
for case in cases:
|
||||
if case not in seen:
|
||||
seen.add(case)
|
||||
ordered.append(case)
|
||||
return ordered
|
||||
|
||||
|
||||
def _lift_sanitize_url() -> str:
|
||||
"""The REAL sanitizeUrl and the two constants it closes over, from common.js.
|
||||
|
||||
Lifted as one contiguous span rather than three declarations: SAFE_URL_SCHEMES
|
||||
is an array literal, so a brace-matching lift would run past it into the
|
||||
function body and declare the constants twice.
|
||||
"""
|
||||
span = JS.lift_span("common.js", "export const SAFE_URL_SCHEMES",
|
||||
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
|
||||
# safeHref needs sanitizeHTML, which this guard does not exercise; drop the
|
||||
# trailing line rather than pulling an unrelated dependency into the script.
|
||||
span = span[:span.index("export const safeHref")]
|
||||
return span.replace("export ", "")
|
||||
|
||||
|
||||
_JS_ALLOW_LIST_LITERAL = re.compile(r"(SAFE_URL_SCHEMES\s*=\s*)\[[^\]]*\]")
|
||||
|
||||
|
||||
def _js_allow_list(lifted_js=None):
|
||||
"""The JS SAFE_URL_SCHEMES as the running code actually sees it.
|
||||
|
||||
Evaluated in node rather than parsed out of the source, so a change to how
|
||||
the list is spelled cannot make this silently read the old value.
|
||||
"""
|
||||
return run_node(
|
||||
"%s\nconsole.log(JSON.stringify({v: SAFE_URL_SCHEMES}));\n"
|
||||
% (lifted_js if lifted_js is not None else _lift_sanitize_url())
|
||||
)["v"]
|
||||
|
||||
|
||||
def _js_trim_set(lifted_js=None):
|
||||
"""Every codepoint the JS side strips off the edges of an accepted URL.
|
||||
|
||||
Evaluated in node, for the same reason ``_js_allow_list`` is: a set parsed
|
||||
out of the source text stops describing the running code the moment the
|
||||
spelling changes.
|
||||
|
||||
Shimmed on a PRE-FIX tree, where sanitizeUrl called ``raw.trim()`` inline and
|
||||
no ``trimUrl`` exists. Without the shim this guard would raise on the old
|
||||
revision instead of reporting the divergence, and the RED half of the fix
|
||||
could not be demonstrated with the guard that is supposed to catch it.
|
||||
"""
|
||||
lifted = _lift_sanitize_url() if lifted_js is None else lifted_js
|
||||
shim = "" if "trimUrl" in lifted else "const trimUrl = (value) => value.trim();\n"
|
||||
return set(run_node(
|
||||
"%s\n%sconst out = [];\n"
|
||||
"for (let c = 0; c < 0x110000; c += 1) {\n"
|
||||
" if (trimUrl(String.fromCodePoint(c)) === '') out.push(c);\n"
|
||||
"}\n"
|
||||
"console.log(JSON.stringify({v: out}));\n" % (lifted, shim)
|
||||
)["v"])
|
||||
|
||||
|
||||
def _perturb_js_allow_list(lifted_js, extra_scheme):
|
||||
"""Add a scheme to the lifted JS allow-list, whatever it currently holds.
|
||||
|
||||
Rewriting the array by PATTERN rather than by a hard-coded literal matters:
|
||||
a literal-match perturbation stops applying the moment someone edits the
|
||||
allow-list, and then the harness's own did-it-apply self-test fires instead
|
||||
of the parity comparison — a red that looks like coverage and is not.
|
||||
"""
|
||||
perturbed, count = _JS_ALLOW_LIST_LITERAL.subn(
|
||||
lambda m: "%s%s" % (m.group(1), json.dumps(sorted(
|
||||
set(_js_allow_list(lifted_js)) | {extra_scheme}))),
|
||||
lifted_js, count=1,
|
||||
)
|
||||
assert count == 1, "could not locate the JS SAFE_URL_SCHEMES array to perturb"
|
||||
return perturbed
|
||||
|
||||
|
||||
def _js_results(cases):
|
||||
"""Run the REAL js/common.js sanitizeUrl over every case, in node."""
|
||||
lifted = _lift_sanitize_url()
|
||||
return run_node(
|
||||
"%s\nconst cases = %s;\n"
|
||||
"console.log(JSON.stringify({results: cases.map((c) => sanitizeUrl(c))}));\n"
|
||||
% (lifted, json.dumps(cases))
|
||||
)["results"]
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class UrlSanitizeParityTest(unittest.TestCase):
|
||||
"""Both real implementations, one case list, zero tolerated divergence."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.manager_util = load_manager_util()
|
||||
cls.allow_lists = {
|
||||
"python": set(cls.manager_util.SAFE_URL_SCHEMES),
|
||||
"js": set(_js_allow_list()),
|
||||
}
|
||||
cls.cases = _all_cases(cls.allow_lists["python"] | cls.allow_lists["js"])
|
||||
cls.js = _js_results(cls.cases)
|
||||
|
||||
def test_case_matrix_is_actually_loaded(self):
|
||||
# A guard that silently ran zero cases would pass forever.
|
||||
matrix = json.loads(CASES_PATH.read_text(encoding="utf-8"))
|
||||
self.assertGreaterEqual(len(matrix["shared_matrix"]), 57)
|
||||
self.assertGreater(len(self.cases), 400)
|
||||
self.assertEqual(len(self.js), len(self.cases))
|
||||
|
||||
def test_the_scheme_allow_lists_are_identical(self):
|
||||
"""Compare the CONFIGURATION, not only the behaviour it drives.
|
||||
|
||||
Comparing behaviour over sampled inputs cannot see a scheme added to one
|
||||
allow-list unless that scheme happens to be in the sample — adding 'ftp'
|
||||
or 'mailto' or 'ws' to either side changes what is accepted and produces
|
||||
no divergence at all. That is the most plausible future drift for this
|
||||
pair (someone adds a scheme for a real reason and forgets the other
|
||||
side), so it is closed definitively here rather than by sampling.
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.allow_lists["js"], self.allow_lists["python"],
|
||||
"the URL scheme allow-lists have diverged — js/common.js accepts %r "
|
||||
"and glob/manager_util.py accepts %r. A scheme added to one side and "
|
||||
"not the other silently changes what reaches an href. Fix the lists, "
|
||||
"do NOT relax this comparison."
|
||||
% (sorted(self.allow_lists["js"]), sorted(self.allow_lists["python"])),
|
||||
)
|
||||
|
||||
def test_the_trim_sets_are_identical(self):
|
||||
"""Compare the trim CONFIGURATION, the way the allow-lists are compared.
|
||||
|
||||
Both implementations end an accepted URL with a trim, and the two trims
|
||||
are NOT the same function: ``String.prototype.trim()`` strips U+FEFF,
|
||||
which ``str.strip()`` keeps, and leaves U+001C-001F and U+0085, which
|
||||
``str.strip()`` strips. Sampling cannot be relied on to find that — the
|
||||
handed-over matrix had ASCII edges on all 62 inputs and reported no
|
||||
divergence for years of cases. The cases added alongside this test do
|
||||
catch it, but the set comparison is what closes the axis DEFINITIVELY,
|
||||
exactly as test_the_scheme_allow_lists_are_identical does for schemes.
|
||||
|
||||
The fix direction is fixed, not free: glob/manager_util.py is the
|
||||
declared source of truth for this pair, so the JS side matches Python.
|
||||
"""
|
||||
source = inspect.getsource(self.manager_util.sanitize_url)
|
||||
self.assertIn(
|
||||
"raw.strip()", source,
|
||||
"sanitize_url no longer returns raw.strip(), so PYTHON_TRIM_SET is "
|
||||
"measuring the wrong function — re-derive it from whatever the "
|
||||
"Python side trims with now.",
|
||||
)
|
||||
js = _js_trim_set()
|
||||
self.assertEqual(
|
||||
js, set(PYTHON_TRIM_SET),
|
||||
"the URL trim sets have diverged. js/common.js strips %s that "
|
||||
"glob/manager_util.py keeps, and keeps %s that it strips. Both sides "
|
||||
"must trim the SAME set or the two implementations answer differently "
|
||||
"for the same accepted URL; fix js/common.js's URL_TRIM, do NOT relax "
|
||||
"this comparison."
|
||||
% (sorted("U+%04X" % c for c in js - PYTHON_TRIM_SET) or "nothing",
|
||||
sorted("U+%04X" % c for c in PYTHON_TRIM_SET - js) or "nothing"),
|
||||
)
|
||||
|
||||
def test_js_and_python_agree_on_every_case(self):
|
||||
divergences = []
|
||||
for case, js_answer in zip(self.cases, self.js):
|
||||
py_answer = self.manager_util.sanitize_url(case)
|
||||
if py_answer != js_answer:
|
||||
divergences.append(
|
||||
" input=%r\n python=%r\n js =%r" % (case, py_answer, js_answer)
|
||||
)
|
||||
self.assertEqual(
|
||||
divergences, [],
|
||||
"js/common.js sanitizeUrl has drifted from "
|
||||
"glob/manager_util.py::sanitize_url over %d case(s):\n%s\n"
|
||||
"Fix the implementations so they agree again — do NOT loosen this "
|
||||
"comparison, which is the only thing coupling them."
|
||||
% (len(divergences), "\n".join(divergences)),
|
||||
)
|
||||
|
||||
def test_null_and_undefined_agree(self):
|
||||
"""The two non-string inputs both sides handle explicitly.
|
||||
|
||||
Everything else non-string is out of domain — see the module docstring.
|
||||
"""
|
||||
out = run_node(
|
||||
"%s\nconsole.log(JSON.stringify({"
|
||||
"nul: sanitizeUrl(null), undef: sanitizeUrl(undefined)}));\n"
|
||||
% _lift_sanitize_url()
|
||||
)
|
||||
self.assertEqual(out["nul"], self.manager_util.sanitize_url(None))
|
||||
self.assertEqual(out["undef"], "#")
|
||||
|
||||
def _divergences_against(self, lifted_js):
|
||||
"""Run the SAME comparison test_js_and_python_agree_on_every_case runs."""
|
||||
js = run_node(
|
||||
"%s\nconst cases = %s;\n"
|
||||
"console.log(JSON.stringify({results: cases.map((c) => sanitizeUrl(c))}));\n"
|
||||
% (lifted_js, json.dumps(self.cases))
|
||||
)["results"]
|
||||
return [
|
||||
case for case, answer in zip(self.cases, js)
|
||||
if self.manager_util.sanitize_url(case) != answer
|
||||
]
|
||||
|
||||
def test_the_guard_goes_red_on_each_perturbation_of_the_js_side(self):
|
||||
"""Prove the comparison has teeth rather than asserting that it does.
|
||||
|
||||
Each perturbation is a shape a real regression takes — a scheme added to
|
||||
the allow-list, the control-char strip losing its /g flag so only the
|
||||
FIRST noise byte is removed, the scheme-less branch changing its answer,
|
||||
and the empty-input fallback changing. Every one must be caught by the
|
||||
case list as it stands, so a guard whose lift silently returned a stub,
|
||||
or whose matrix had rotted into irrelevance, cannot look healthy.
|
||||
"""
|
||||
base = _lift_sanitize_url()
|
||||
perturbations = {
|
||||
# By PATTERN, not by literal: a literal-match perturbation stops
|
||||
# applying the moment someone edits the allow-list, and the
|
||||
# did-it-apply assertion below then fires INSTEAD of the parity
|
||||
# comparison — a red that reads like coverage and is not.
|
||||
"allow-list gains a scheme": _perturb_js_allow_list(base, "javascript"),
|
||||
"noise strip loses its /g flag":
|
||||
base.replace("/[\\x00-\\x20\\x7f]/g", "/[\\x00-\\x20\\x7f]/", 1),
|
||||
"scheme-less branch stops trimming":
|
||||
base.replace("return trimUrl(raw);\n}", "return raw;\n}", 1),
|
||||
# The regression this fix closed: delegating back to String.trim(),
|
||||
# which strips U+FEFF that str.strip() keeps and keeps U+001C-001F
|
||||
# and U+0085 that str.strip() strips. Caught only by the unicode
|
||||
# cases added with the fix — before them the matrix had ASCII edges
|
||||
# throughout and this perturbation produced zero divergence.
|
||||
"trim delegates back to String.trim()":
|
||||
base.replace("trimUrl(raw)", "raw.trim()"),
|
||||
"empty-input fallback changes": base.replace("return '#';", "return '';", 1),
|
||||
}
|
||||
for label, lifted in perturbations.items():
|
||||
with self.subTest(perturbation=label):
|
||||
self.assertNotEqual(
|
||||
lifted, base,
|
||||
"perturbation %r did not apply — this is the HARNESS "
|
||||
"self-test, NOT the parity comparison. Do not read it as "
|
||||
"coverage: fix the perturbation so the comparison is "
|
||||
"actually exercised." % (label,),
|
||||
)
|
||||
caught = self._divergences_against(lifted)
|
||||
self.assertNotEqual(
|
||||
caught, [],
|
||||
"perturbing the JS side (%s) produced NO divergence — the case "
|
||||
"list cannot detect this drift, so the guard is weaker than it "
|
||||
"looks. Add a case that distinguishes it." % (label,),
|
||||
)
|
||||
|
||||
def test_the_guard_goes_red_when_either_allow_list_gains_a_scheme(self):
|
||||
"""The perturbation that was NOT caught before this test existed.
|
||||
|
||||
Both directions are exercised, because the drift can start on either
|
||||
side and the guard is only worth having if it is symmetric. 'ftp' is used
|
||||
deliberately: it is absent from the shared matrix, so nothing but the
|
||||
allow-list comparison itself can catch it.
|
||||
"""
|
||||
with self.subTest(side="python (the declared SoT)"):
|
||||
real = self.manager_util.SAFE_URL_SCHEMES
|
||||
try:
|
||||
setattr(self.manager_util, "SAFE_URL_SCHEMES", frozenset(set(real) | {"ftp"}))
|
||||
self.assertNotEqual(
|
||||
set(self.manager_util.SAFE_URL_SCHEMES), self.allow_lists["js"],
|
||||
"adding a scheme to the PYTHON allow-list produced no "
|
||||
"divergence — the configuration axis is unguarded again",
|
||||
)
|
||||
finally:
|
||||
setattr(self.manager_util, "SAFE_URL_SCHEMES", real)
|
||||
self.assertEqual(set(self.manager_util.SAFE_URL_SCHEMES), self.allow_lists["python"])
|
||||
|
||||
with self.subTest(side="js"):
|
||||
perturbed = _js_allow_list(_perturb_js_allow_list(_lift_sanitize_url(), "ftp"))
|
||||
self.assertIn("ftp", perturbed, "the JS perturbation did not apply")
|
||||
self.assertNotEqual(
|
||||
set(perturbed), self.allow_lists["python"],
|
||||
"adding a scheme to the JS allow-list produced no divergence",
|
||||
)
|
||||
|
||||
def test_the_guard_goes_red_on_a_perturbed_python_side(self):
|
||||
"""The coupling has to run both ways, not just JS-drifts-from-Python."""
|
||||
real = self.manager_util.sanitize_url
|
||||
try:
|
||||
setattr(self.manager_util, "sanitize_url", lambda url: "#")
|
||||
caught = self._divergences_against(_lift_sanitize_url())
|
||||
finally:
|
||||
setattr(self.manager_util, "sanitize_url", real)
|
||||
self.assertNotEqual(
|
||||
caught, [],
|
||||
"perturbing the PYTHON side produced no divergence — the comparison "
|
||||
"is not reading the Python implementation it claims to",
|
||||
)
|
||||
|
||||
def test_sanitize_url_has_exactly_one_client_side_home(self):
|
||||
"""The consolidation this guard exists to protect."""
|
||||
js_dir = JS.js_dir
|
||||
homes = sorted(
|
||||
path.name for path in js_dir.glob("*.js")
|
||||
if "function sanitizeUrl" in path.read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(
|
||||
homes, ["common.js"],
|
||||
"sanitizeUrl must live only in common.js; every other client file "
|
||||
"imports it. A second copy is the drift this guard cannot see, "
|
||||
"because it only compares the copy in common.js.",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user