332 lines
15 KiB
Python
332 lines
15 KiB
Python
"""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()
|