From e64c5f1be4bb20e710ed7d622799b8f21dfdf80c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 18 May 2026 07:08:51 +0000 Subject: [PATCH 1/2] fix(ui): preview-PNG data-URL must use template.URL type Go html/template escapes "data:image/png;base64,..." in src= attributes to "#ZgotmplZ" by default as a security guard against `data:text/html` XSS vectors. PreviewURI was typed `string` in TemplateDetailData, so the rendered Template-Detail-Page showed the escape marker instead of the actual preview PNG. Wrap the URI value in `template.URL` to mark it as already-safe. The type is preserved through assignment so both the placeholder SVG path and the data-URL flow correctly. Regression test asserts the rendered body contains the data-URL prefix AND does NOT contain the ZgotmplZ marker. Stub template extended to render so the test catches the bug. Closes #87 Refs #22 --- frontend/internal/handlers/base.go | 4 +-- frontend/internal/handlers/template.go | 13 +++++++--- frontend/internal/handlers/template_test.go | 27 +++++++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) 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) {

From 562f7c92c21645fea8dcf472d91340c1776a1a8f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?=
 
Date: Mon, 18 May 2026 07:40:08 +0000
Subject: [PATCH 2/2] fix(ci): bump frontend Dockerfile to golang:1.25-alpine

Dependabot PR #89 bumped frontend/go.mod to `go 1.25.0`, but the
Dockerfile builder image was still `golang:1.24-alpine`, breaking
the Docker publish workflow on every PR:

  go: go.mod requires go >= 1.25.0 (running go 1.24.13; GOTOOLCHAIN=local)

Bump the builder image to `golang:1.25-alpine` so it matches the
toolchain version declared in go.mod.

Refs #22
---
 frontend/Dockerfile | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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