Fix XSS vulnerabilities in snippet annotations and plugin template

Change-Id: I7cd476e4cddc785eff465d6f5595bdbbe8aa9f45
diff --git a/mapper/response.go b/mapper/response.go
index 3ac6b02..61ff94b 100644
--- a/mapper/response.go
+++ b/mapper/response.go
@@ -2,6 +2,7 @@
 
 import (
 	"fmt"
+	"html"
 	"maps"
 	"strings"
 
@@ -253,7 +254,7 @@
 
 				annotated := escapeXMLText(trimmed)
 				for i := len(annotationStrings) - 1; i >= 0; i-- {
-					annotated = fmt.Sprintf(`<span title="%s" class="notinindex">%s</span>`, annotationStrings[i], annotated)
+					annotated = fmt.Sprintf(`<span title="%s" class="notinindex">%s</span>`, html.EscapeString(annotationStrings[i]), annotated)
 				}
 				result.WriteString(annotated)
 				result.WriteString(trailingWS)
diff --git a/mapper/response_test.go b/mapper/response_test.go
index 9a0c668..8452730 100644
--- a/mapper/response_test.go
+++ b/mapper/response_test.go
@@ -931,6 +931,84 @@
 	assert.Contains(t, snippet, `<span title="opennlp/p:NOUN" class="notinindex">Mann</span>`)
 }
 
+// TestResponseAnnotationHTMLEscaping verifies that annotation strings containing
+// HTML-special characters are properly escaped in the title attribute to prevent XSS.
+func TestResponseAnnotationHTMLEscaping(t *testing.T) {
+	responseSnippet := `{
+		"snippet": "<span title=\"marmot/p:DET\">Der</span>"
+	}`
+
+	// Mapping rule where the replacement foundry contains a quote character
+	// that could break out of the HTML title attribute if unescaped.
+	mappingList := config.MappingList{
+		ID:       "test-xss-mapper",
+		FoundryA: "marmot",
+		LayerA:   "p",
+		FoundryB: `foo" onmouseover="alert(1)" x="`,
+		LayerB:   "p",
+		Mappings: []config.MappingRule{
+			"[DET] <> [DT]",
+		},
+	}
+
+	m, err := NewMapper([]config.MappingList{mappingList})
+	require.NoError(t, err)
+
+	var inputData any
+	err = json.Unmarshal([]byte(responseSnippet), &inputData)
+	require.NoError(t, err)
+
+	result, err := m.ApplyResponseMappings("test-xss-mapper", MappingOptions{Direction: AtoB}, inputData)
+	require.NoError(t, err)
+
+	resultMap := result.(map[string]any)
+	snippet := resultMap["snippet"].(string)
+
+	// The quote character MUST be escaped (as &#34; or &quot;) in the title attribute
+	// so it cannot break out. The raw unescaped sequence must not appear.
+	assert.NotContains(t, snippet, `title="foo" onmouseover="alert(1)"`)
+	// The escaped version should be present (&#34; is the html.EscapeString encoding for ")
+	assert.Contains(t, snippet, `&#34;`)
+	assert.Contains(t, snippet, `class="notinindex"`)
+}
+
+// TestResponseAnnotationHTMLEscapingAngleBrackets verifies that angle brackets
+// in annotation strings are escaped to prevent HTML injection.
+func TestResponseAnnotationHTMLEscapingAngleBrackets(t *testing.T) {
+	responseSnippet := `{
+		"snippet": "<span title=\"marmot/p:DET\">Der</span>"
+	}`
+
+	mappingList := config.MappingList{
+		ID:       "test-angle-mapper",
+		FoundryA: "marmot",
+		LayerA:   "p",
+		FoundryB: "<script>",
+		LayerB:   "p",
+		Mappings: []config.MappingRule{
+			"[DET] <> [DT]",
+		},
+	}
+
+	m, err := NewMapper([]config.MappingList{mappingList})
+	require.NoError(t, err)
+
+	var inputData any
+	err = json.Unmarshal([]byte(responseSnippet), &inputData)
+	require.NoError(t, err)
+
+	result, err := m.ApplyResponseMappings("test-angle-mapper", MappingOptions{Direction: AtoB}, inputData)
+	require.NoError(t, err)
+
+	resultMap := result.(map[string]any)
+	snippet := resultMap["snippet"].(string)
+
+	// Angle brackets must be escaped in the title attribute
+	assert.NotContains(t, snippet, `<script>`)
+	assert.Contains(t, snippet, `&lt;script&gt;`)
+	assert.Contains(t, snippet, `class="notinindex"`)
+}
+
 // TestResponseMappingWithLayerOverride tests layer precedence rules
 func TestResponseMappingWithLayerOverride(t *testing.T) {
 	// Test 1: Explicit layer in mapping rule should take precedence over MappingOptions