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">' TEXT_PAYLOAD = "" 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,'})", "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", 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