diff --git a/hscontrol/debug.go b/hscontrol/debug.go
index 096ea25b..c27b3975 100644
--- a/hscontrol/debug.go
+++ b/hscontrol/debug.go
@@ -361,7 +361,6 @@ func (h *Headscale) debugHTTPServer() *http.Server {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
- //nolint:gosec // elem-go auto-escapes all attribute values; no XSS risk.
_, _ = w.Write([]byte(templates.PingPage(query, result, nodes).Render()))
}))
diff --git a/hscontrol/templates/ping.go b/hscontrol/templates/ping.go
index f7e2c63e..f4d2fb3c 100644
--- a/hscontrol/templates/ping.go
+++ b/hscontrol/templates/ping.go
@@ -2,6 +2,7 @@ package templates
import (
"fmt"
+ "html"
"strings"
"time"
@@ -101,7 +102,7 @@ func pingForm(query string) *elem.Element {
elem.Input(attrs.Props{
attrs.Type: "text",
attrs.Name: "node",
- attrs.Value: query,
+ attrs.Value: html.EscapeString(query),
attrs.Placeholder: "Node ID, IP, or hostname",
attrs.Autofocus: "true",
attrs.Style: styles.Props{
diff --git a/hscontrol/templates/ping_test.go b/hscontrol/templates/ping_test.go
new file mode 100644
index 00000000..3e80cf8f
--- /dev/null
+++ b/hscontrol/templates/ping_test.go
@@ -0,0 +1,26 @@
+package templates
+
+import (
+ "strings"
+ "testing"
+)
+
+// TestPingPageEscapesQuery asserts hostile query values cannot break out of
+// the input's value attribute. elem-go does not escape attribute values, so
+// the template must escape before rendering.
+func TestPingPageEscapesQuery(t *testing.T) {
+ payloads := []string{
+ `" autofocus onfocus=alert(1) x="`,
+ `">`,
+ `
`,
+ }
+
+ for _, p := range payloads {
+ t.Run(p, func(t *testing.T) {
+ out := PingPage(p, nil, nil).Render()
+ if strings.Contains(out, p) {
+ t.Fatalf("unescaped payload rendered verbatim: %q", p)
+ }
+ })
+ }
+}