html: check all ancestor namespace transitions when rendering raw text

In childTextNodesAreLiteral, walking up the tree previously stopped at
the nearest foreign ancestor once an HTML integration point was found.
However, that integration point may itself be a descendant of an element
that was foster-parented into a foreign namespace (such as <mglyph> or
<malignmark> fostered under <mtext>), causing a parser to resolve the
subtree in a different foreign namespace upon re-parsing the rendered
output.

Continue walking the full ancestor chain and verify that every
transition from a non-empty namespace occurs at an HTML integration
point before emitting literal text.

Thanks to Ali Sherif (https://www.linkedin.com/in/ali-sherif-13812b276)
for reporting this issue.

Fixes CVE-2026-97045
For golang/go#82622

Change-Id: I67d0006dce9023451905d8e96c4d1f3d05e769cc
Reviewed-on: https://go-internal-review.googlesource.com/c/net/+/6360
Reviewed-by: Nicholas Husin <husin@google.com>
Reviewed-by: Neal Patel <nealpatel@google.com>
Reviewed-on: https://go-review.googlesource.com/c/net/+/855121
Reviewed-by: Neal Patel <neal@golang.org>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Gopher Robot <gobot@golang.org>
diff --git a/html/render.go b/html/render.go
index 767eeae..f356d48 100644
--- a/html/render.go
+++ b/html/render.go
@@ -246,18 +246,17 @@
 
 	switch n.Data {
 	case "iframe", "noembed", "noframes", "noscript", "plaintext", "script", "style", "xmp":
-		// 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
+		// n's text is literal only if the parser would keep n in the HTML
+		// namespace when the output is re-parsed. An ancestor may have been
+		// fostered under a foreign element (such as an HTML <mglyph> under
+		// <mtext>) where the parser would instead create it, and its
+		// descendants, in that element's namespace. We walk up the whole tree
+		// and check that every transition out of a non-HTML namespace happens
+		// at an HTML integration point. This is stricter than the parser, so
+		// some text that would be safe literally is escaped.
+		for c, p := n, n.Parent; p != nil; c, p = p, p.Parent {
+			if p.Namespace != "" && p.Namespace != c.Namespace && !htmlIntegrationPoint(p) {
+				return false
 			}
 		}
 
diff --git a/html/render_test.go b/html/render_test.go
index 2de92b2..7066a3b 100644
--- a/html/render_test.go
+++ b/html/render_test.go
@@ -207,18 +207,34 @@
 }
 
 func TestRenderFosteredForeignContent(t *testing.T) {
-	a := `<math><mtext><table><mglyph><style><img src=x onerror=alert(1)>`
-	d, err := Parse(strings.NewReader(a))
-	if err != nil {
-		t.Fatal(err)
-	}
-	buf := bytes.NewBuffer(nil)
-	if err := Render(buf, d); err != nil {
-		t.Fatal(err)
-	}
+	for _, tc := range []struct {
+		in   string
+		want string
+	}{
+		{
+			in:   `<math><mtext><table><mglyph><style><img src=x onerror=alert(1)>`,
+			want: "<html><head></head><body><math><mtext><mglyph><style>&lt;img src=x onerror=alert(1)&gt;</style></mglyph><table></table></mtext></math></body></html>",
+		},
+		{
+			in:   `<math><mtext><table><mglyph><svg><desc><style><img src=x onerror=alert(1)>`,
+			want: "<html><head></head><body><math><mtext><mglyph><svg><desc><style>&lt;img src=x onerror=alert(1)&gt;</style></desc></svg></mglyph><table></table></mtext></math></body></html>",
+		},
+		{
+			in:   `<math><mtext><table><malignmark><svg><desc><style><img src=x onerror=alert(1)>`,
+			want: "<html><head></head><body><math><mtext><malignmark><svg><desc><style>&lt;img src=x onerror=alert(1)&gt;</style></desc></svg></malignmark><table></table></mtext></math></body></html>",
+		},
+	} {
+		d, err := Parse(strings.NewReader(tc.in))
+		if err != nil {
+			t.Fatal(err)
+		}
+		buf := bytes.NewBuffer(nil)
+		if err := Render(buf, d); err != nil {
+			t.Fatal(err)
+		}
 
-	expected := "<html><head></head><body><math><mtext><mglyph><style>&lt;img src=x onerror=alert(1)&gt;</style></mglyph><table></table></mtext></math></body></html>"
-	if buf.String() != expected {
-		t.Errorf("unexpected output: got %q, want %q", buf.String(), expected)
+		if buf.String() != tc.want {
+			t.Errorf("unexpected output for %q:\ngot:  %q\nwant: %q", tc.in, buf.String(), tc.want)
+		}
 	}
 }