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("", 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("bold", CONVERT("[a/**bold** text](https://e.com)"))
self.assertIn("hi", 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("white", html)
self.assertIn("bold", 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 '
' be injected into the
# attribute the escaper had secured. Not exploitable — '
' 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("
", 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("
", 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 "), "<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()