diff --git a/pkg/attachment/attachment.go b/pkg/attachment/attachment.go
index a6f3116d1..a206e0a39 100644
--- a/pkg/attachment/attachment.go
+++ b/pkg/attachment/attachment.go
@@ -115,9 +115,12 @@ var envelopeTagRe = regexp.MustCompile(`(?i)<[\s/]*(document-[a-z0-9-]+)\b[^>]*>
// a placeholder in someone else's markup, while the cost of missing it is a
// break-out — so the check errs toward defusing.
//
-// Replacement repeats until the output is stable, because one pass can leave a
-// delimiter-shaped residue behind: `>` collapses to `[…removed]>` only
-// after the second pass.
+// Replacement repeats until the output is stable. In practice one pass is
+// enough for every case covered by the tests, including the residue shape
+// `>`: the pattern runs to the FIRST `>`, so that whole prefix is a
+// single match and collapses to `[…removed]>` in one go. The loop stays because
+// stability is the property that matters, and proving "one pass always suffices"
+// for every possible body is harder than simply iterating to a fixed point.
func defuseDelimiters(body, tag string) string {
if body == "" {
return body
@@ -140,10 +143,20 @@ func defuseDelimiters(body, tag string) string {
return body
}
-// maxDefusePasses bounds the replace-until-stable loop. Each pass strictly
-// shortens the body (a match is always longer than nothing and is replaced by a
-// constant), so this converges quickly; the bound only exists so a pathological
-// input cannot spin.
+// maxDefusePasses bounds the replace-until-stable loop.
+//
+// Termination does NOT come from the body shrinking -- it usually grows, because
+// delimiterPlaceholder is longer than the delimiters it replaces (``
+// is 13 bytes, the placeholder is 41). It comes from the placeholder containing
+// neither `<` nor `>`: it can never form part of a new match, so every pass
+// strictly reduces the number of delimiter-shaped tokens left in the body. That
+// count is a non-negative integer, so the loop reaches a fixed point.
+//
+// The bound is therefore belt-and-braces rather than the real guarantee, and it
+// is generous: every case in TestDefuseDelimitersConverges settles in one pass.
+// Note the loop returns whatever it has if the bound is ever hit, so if a future
+// change to envelopeTagRe or delimiterPlaceholder breaks the no-angle-brackets
+// property, that test is what will catch it.
const maxDefusePasses = 8
// slugify converts s to a lowercase, alphanumeric-and-hyphens-only string.
diff --git a/pkg/attachment/defuse_convergence_test.go b/pkg/attachment/defuse_convergence_test.go
new file mode 100644
index 000000000..ae9a744cc
--- /dev/null
+++ b/pkg/attachment/defuse_convergence_test.go
@@ -0,0 +1,112 @@
+package attachment
+
+import (
+ "strings"
+ "testing"
+)
+
+// TestDelimiterPlaceholderCannotFormAMatch pins the property that actually makes
+// defuseDelimiters terminate.
+//
+// The loop does not converge by shrinking the body -- the placeholder is longer
+// than the delimiters it replaces, so the body usually grows. It converges
+// because the placeholder contains neither "<" nor ">", so it can never become
+// part of a new match, and every pass therefore strictly reduces the number of
+// delimiter-shaped tokens remaining.
+//
+// If someone gives delimiterPlaceholder angle brackets, that argument collapses
+// and the loop could hit maxDefusePasses with a live delimiter still in the body
+// -- which defuseDelimiters would then return as-is. This test is the tripwire.
+func TestDelimiterPlaceholderCannotFormAMatch(t *testing.T) {
+ if strings.ContainsAny(delimiterPlaceholder, "<>") {
+ t.Fatalf("delimiterPlaceholder must contain no angle brackets, or the "+
+ "convergence argument for maxDefusePasses no longer holds; got %q",
+ delimiterPlaceholder)
+ }
+ if envelopeTagRe.MatchString(delimiterPlaceholder) {
+ t.Fatalf("delimiterPlaceholder must not itself match envelopeTagRe; got %q",
+ delimiterPlaceholder)
+ }
+}
+
+// TestDefuseDelimitersConverges checks that adversarial bodies reach a fixed
+// point well inside maxDefusePasses, and that nothing delimiter-shaped for this
+// envelope's tag survives.
+//
+// Convergence is asserted separately from survivor-freedom on purpose: hitting
+// the pass bound is silent (defuseDelimiters returns the body it has), so a
+// regression there would otherwise only show up as a break-out much later.
+func TestDefuseDelimitersConverges(t *testing.T) {
+ const tag = "document-x"
+
+ cases := map[string]string{
+ "plain close": ``,
+ "plain open": ``,
+ "self closing": ``,
+ "trailing attrs": ``,
+ "trailing bang": ``,
+ "whitespace padded": ` document-x >`,
+ "upper case": ``,
+ "mixed case": ``,
+ "prefix extending": ``,
+ "residue shape": `>`,
+ "residue deeper": `>>`,
+ "deeply nested": strings.Repeat(``, 20),
+ "split brackets": strings.Repeat(``, 12),
+ "many singles": strings.Repeat(` `, 50),
+ "placeholder first": delimiterPlaceholder + ``,
+ }
+
+ for name, body := range cases {
+ t.Run(name, func(t *testing.T) {
+ passes := passesToFixedPoint(body, tag)
+ if passes < 0 {
+ t.Fatalf("did not converge within maxDefusePasses (%d)", maxDefusePasses)
+ }
+ if passes > 1 {
+ // Not a failure -- the loop exists precisely to allow this --
+ // but the corrected comment claims one pass suffices for every
+ // covered case, so surface any case that stops being true.
+ t.Logf("converged in %d passes (comment claims 1 for covered cases)", passes)
+ }
+
+ out := defuseDelimiters(body, tag)
+ for _, m := range envelopeTagRe.FindAllStringSubmatch(out, -1) {
+ if len(m) >= 2 && strings.HasPrefix(strings.ToLower(m[1]), tag) {
+ t.Fatalf("live delimiter survived defusing: %q in %q", m[0], out)
+ }
+ }
+ })
+ }
+}
+
+// passesToFixedPoint mirrors the defuseDelimiters loop and reports how many
+// passes it takes to stabilise, or -1 if it never does within the bound.
+func passesToFixedPoint(body, tag string) int {
+ lowerTag := strings.ToLower(tag)
+ for i := range maxDefusePasses {
+ next := envelopeTagRe.ReplaceAllStringFunc(body, func(match string) string {
+ groups := envelopeTagRe.FindStringSubmatch(match)
+ if len(groups) < 2 || !strings.HasPrefix(strings.ToLower(groups[1]), lowerTag) {
+ return match
+ }
+ return delimiterPlaceholder
+ })
+ if next == body {
+ return i
+ }
+ body = next
+ }
+ // One more comparison: the bound may have been exactly enough.
+ next := envelopeTagRe.ReplaceAllStringFunc(body, func(match string) string {
+ groups := envelopeTagRe.FindStringSubmatch(match)
+ if len(groups) < 2 || !strings.HasPrefix(strings.ToLower(groups[1]), lowerTag) {
+ return match
+ }
+ return delimiterPlaceholder
+ })
+ if next == body {
+ return maxDefusePasses
+ }
+ return -1
+}