diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a0ea221..11c0e28 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -55,7 +55,7 @@ RUN tailwindcss \ # ----------------------------------------------------------------------------- # Stage 1: builder # ----------------------------------------------------------------------------- -FROM golang:1.24-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /build diff --git a/frontend/internal/handlers/base.go b/frontend/internal/handlers/base.go index 28e8ba6..6dc7d3c 100644 --- a/frontend/internal/handlers/base.go +++ b/frontend/internal/handlers/base.go @@ -190,8 +190,8 @@ var stubPageContent = map[string]string{ {{define "job-content"}}
job
{{end}}`, "templates": `{{define "content"}}
templates
{{end}} {{define "templates-content"}}
templates
{{end}}`, - "template": `{{define "content"}}
template
{{end}} -{{define "template-content"}}
template
{{end}}`, + "template": `{{define "content"}}
template
{{end}} +{{define "template-content"}}
template
{{end}}`, "lookup": `{{define "content"}}
lookup
{{end}} {{define "lookup-content"}}
lookup
{{end}}`, } diff --git a/frontend/internal/handlers/template.go b/frontend/internal/handlers/template.go index 5b31fa8..7a6068c 100644 --- a/frontend/internal/handlers/template.go +++ b/frontend/internal/handlers/template.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/base64" + "html/template" "net/http" "time" @@ -13,8 +14,12 @@ import ( // TemplateDetailData holds the template variables for the template detail page. type TemplateDetailData struct { TemplateData - Template *api.TemplateRead - PreviewURI string // base64 data URI or /static/preview-placeholder.svg + Template *api.TemplateRead + // PreviewURI is either a base64 data URL (rendered preview) or a static + // SVG path. The template.URL type is required so html/template does NOT + // escape "data:image/png;base64,..." as a potentially dangerous URL — + // without the type wrap, src= becomes "#ZgotmplZ" (issue #87). + PreviewURI template.URL YAMLSource string // raw YAML source for display in
 }
 
@@ -57,12 +62,12 @@ func (h *PageHandler) TemplateDetailWithKey(w http.ResponseWriter, r *http.Reque
 
 	// Request a preview PNG from the backend's render endpoint.
 	// A 2-second sub-context timeout prevents a slow render from blocking the page.
-	previewURI := "/static/preview-placeholder.svg"
+	previewURI := template.URL("/static/preview-placeholder.svg")
 	previewCtx, previewCancel := context.WithTimeout(r.Context(), 2*time.Second)
 	defer previewCancel()
 	previewBytes, previewErr := h.client.RenderPreview(previewCtx, key)
 	if previewErr == nil && len(previewBytes) > 0 {
-		previewURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(previewBytes)
+		previewURI = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(previewBytes))
 	}
 
 	h.renderPage(w, r, "template", TemplateDetailData{
diff --git a/frontend/internal/handlers/template_test.go b/frontend/internal/handlers/template_test.go
index 7f77794..42df3d9 100644
--- a/frontend/internal/handlers/template_test.go
+++ b/frontend/internal/handlers/template_test.go
@@ -120,6 +120,33 @@ func TestTemplateDetailPreviewTimeout(t *testing.T) {
 	}
 }
 
+// TestTemplateDetailPreviewDataURLNotEscaped is the regression test for
+// issue #87: html/template was escaping the `data:image/png;base64,...` URL
+// in src= attributes to `#ZgotmplZ` because PreviewURI was typed `string`
+// (default url-sanitisation kicks in). After the fix PreviewURI is
+// template.URL, marking it as already-safe so it round-trips through the
+// rendered HTML unmodified.
+func TestTemplateDetailPreviewDataURLNotEscaped(t *testing.T) {
+	t.Parallel()
+	backend := templateDetailBackend(t, true) // serve preview PNG
+	defer backend.Close()
+	ph := handlers.NewPageHandlerFromURL(t, backend.URL)
+	req := httptest.NewRequest(http.MethodGet, "/templates/"+templateKey, nil)
+	req.Header.Set("HX-Request", "true")
+	w := httptest.NewRecorder()
+	ph.TemplateDetailWithKey(w, req, templateKey)
+	if w.Code != http.StatusOK {
+		t.Fatalf("status %d, body: %s", w.Code, w.Body.String())
+	}
+	body := w.Body.String()
+	if !strings.Contains(body, "data:image/png;base64,") {
+		t.Errorf("preview src must contain data:image/png;base64 URL, got: %s", body)
+	}
+	if strings.Contains(body, "ZgotmplZ") {
+		t.Errorf("preview src is html-template-escaped (ZgotmplZ marker); PreviewURI must use template.URL type, got: %s", body)
+	}
+}
+
 func TestTemplateDetailBackendError(t *testing.T) {
 	t.Parallel()
 	backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {