From an early break to XSS
One loop in x/net/html's Render trusted its first safe ancestor. Across 2,880 shapes, 240 came back as a live <img>; v0.62.0 closes all of them and escapes 225 that were clean.
If your Go code calls html.Parse, edits the tree and writes it back with html.Render, you trust one property of Render: the text it emits is read back as text by the next parser. goquery’s Html() is that pattern, and a sanitizer that walks the parsed tree and re-serializes it is the same pattern with a browser as the next parser. On v0.61.0 the property had a hole. Render decides, one raw-text node at a time, whether that node’s text ships raw or escaped, and its ancestor check used to stop at the first foreign ancestor that looked safe. In my census of 2,880 shapes, 240 came back from Render as a live <img> with an onerror. That is mutation XSS, markup one parser holds as text and the next parser turns into an element. GO-2026-6866 (CVE-2026-97045) landed on October 27 in v0.62.0 for the reported shapes; the vulnerability database lists Render as the affected symbol, so govulncheck flags any binary that reaches it. My census says the fix swept all 240 in the corpus, and that it did so by escaping more: 225 round trips that parsed back to themselves on v0.61.0 stop doing it on v0.62.0.
The reparse
The round trip I test is render(parse(x)) parsing back to the same DOM. That is this census’s criterion, not a promise the package makes: Render’s own doc calls its output best effort, “not necessarily an exact clone” of the tree it was given. So the census treats any difference as a divergence, then asks the one question a sanitizer cares about: does text come back as markup. Anything that re-serializes a parsed tree and lets another parser read the string leans on that answer; a sanitizer is the case where that second parser is a browser. The input I start from is one line of eight tags, one of the two regression-test cases the fix CL added:
<math><mtext><table><malignmark><svg><desc><style><img src=x onerror=alert(1)>Run it through the first parse and the payload lands as text inside <style>, inside an SVG subtree hanging off a <malignmark> shell; <malignmark> and <table> come out as siblings under the MathML mtext node. Render then decides whether that text ships raw:
<html><head></head><body><math><mtext><malignmark><svg><desc><style><img src=x onerror=alert(1)></style></desc></svg></malignmark><table></table></mtext></math></body></html><html><head></head><body><math><mtext><malignmark><svg><desc><style><img src=x onerror=alert(1)></style></desc></svg></malignmark><table></table></mtext></math></body></html>The v0.61.0 render re-parses with the payload as a live img; the census case math/mtext/trig=table/elem=malignmark/nested=<svg><desc>/raw=style in results-v0.61.json carries :img(onerror=alert(1),src=x) in its re-parse signature. The v0.62.0 render stays escaped text. In the same probe run, the minimal shape that v0.61.0’s own render_test.go already pinned, <math><mtext><table><mglyph><style><img src=x onerror=alert(1)>, was escaped on both versions: the two regression-test cases added by the fix, <mglyph><svg><desc> and <malignmark><svg><desc>, are where the bug actually lived. All three come out of go run -modfile=go.v061.mod . -probes; the four commands behind every number below are in the harness README.
Why the walk stopped early
The pre-fix check in childTextNodesAreLiteral, verbatim:
// We need to check if n is a node that was fostered from a HTML namespace// into a non-HTML namespace (in which case, different rules apply to it).// We do this by walking up the tree until we find a node with a non-empty// namespace. If we find such a node, we also have to check if it's// an HTML integration point. If it isn't, then the node we're currently// looking at is foster-parented and we should return false.for p := n.Parent; p != nil; p = p.Parent { if p.Namespace != "" { if !htmlIntegrationPoint(p) { return false } break }}The walk ends at the first foreign ancestor and, when that ancestor is an integration point, breaks and trusts everything above it. The wrong assumption is that the integration point settles the subtree’s namespace. It does not: the integration point can itself sit under an HTML-namespace shell foster-parented into foreign content, and above that shell there can be another transition out of the foreign namespace that is not an integration point.
The fix (v0.62.0)
The walk keeps going and judges every transition:
for c, p := n, n.Parent; p != nil; c, p = p, p.Parent { if p.Namespace != "" && p.Namespace != c.Namespace && !htmlIntegrationPoint(p) { return false }}It is CL 855121, commit 3de643db506517b3e652a13cc0b56034cea6b8e6, plus 11 lines and minus 12 in render.go, with the regression test grown from one case to three. The tag also touches parse.go, a text-node coalescing fix; it does not reach these results: every first-parse signature the runs record is identical. The new comment states the trade: “This is stricter than the parser, so some text that would be safe literally is escaped.”
The round-trip census
The first grammar run left out the dimension the fix cares about: a second foreign subtree nested below the fostered shell. Nothing changed between versions in that run, so I discarded it and let the shells describe themselves:
root math | svgwrapper (IP) math: none, mi mo mn ms mtext, annotation-xml, annotation-xml(HTML) svg: none, desc, foreignObject, titlefoster table | noneshell mglyph | malignmark | spannested foreign none | svg>desc | svg>foreignObject | svg>title | math>mtextraw text iframe noembed noframes noscript plaintext script style xmpSix axes, one fixed payload (<img src=x onerror=alert(1)>), 12 root-and-wrapper pairings times two foster triggers times three shells times five nested subtrees times eight raw-text elements: 2,880 inputs, each parsed as a whole document (html.Parse), rendered, then re-parsed, with DOM signatures compared as namespace:tag(attribute) and text entries, each tagged with its depth in the tree:
VERSION v0.61.0SUMMARY M=2880 N=1059
VERSION v0.62.0SUMMARY M=2880 N=1284Every cell below is one of the 2,880 shapes. Click one to see what each version’s Render emits and what the re-parse makes of it; the ringed cells carry the full trees.
The counts behind “240 with a live element” are a filter over the result JSON: cases where divergent is true and the re-parse signature contains :img(onerror=alert(1),src=x). That is 240 on v0.61.0, zero on v0.62.0. All eight raw-text elements from the renderer’s own case list contribute 30 shapes each; script is not special in the census.
The 240 and the 225
The 240 are a closed product: five MathML text integration points (48 each) times two shells, mglyph and malignmark, never span (120 each) times three SVG integration points nested in the shell (80 each) times the eight raw-text elements (30 each). Every ingredient matches the report: a MathML integration point, a foster-parented shell, an SVG subtree inside it. The payload is the input fragment, so reach never depends on the census: wherever a vulnerable version renders one of these shapes and the output gets re-parsed, the <img> comes back alive. Deployment wiring is outside the census.
The 225 are the cost. They are one family: the span shell, five MathML integration points, three SVG integration points, both foster triggers, all raw-text elements except plaintext under tables. None of them re-parses markup: what changed is that every re-parse text node now carries the literal <img src=x onerror=alert(1)>, in fifteen plaintext shapes with the trailing close tags swallowed into the same text node, and nothing ever decodes it back into markup. A <noscript> under a desc integration point, where the re-parse reads the entity literally, is one of 30 shapes with the same raw-text element; the desc integration points as a whole take 75 of the 225.
The remaining 819 non-fixed points diverge on both versions, and in the same three ways: text that v0.61.0 already escaped and the re-parse reads literally, a shell that comes back in the MathML namespace, or a fostered <table> that drops out. 312 of them have no table at all. None of them introduces a live element; the payload survives only as text. The 240 themselves stay divergent on the fixed version too, as harmless escaped shapes rather than markup. I count them separately so the classified numbers stay attributable.
The verdict
The report names two shells; my corpus says the family is 240, and the fix closed all of it. The closure mechanism is escaping, and it bills 225 round trips that used to agree with themselves, every one a synthetic shape from this grammar. The zero on the second run belongs to this census, six axes and one payload, not to html.Render everywhere. The README says which file each number in this post comes from.