feat(server/m7-363): branding-aware index handler with html/template
This commit is contained in:
@@ -118,7 +118,7 @@ func (s *Server) Router() http.Handler {
|
||||
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer)
|
||||
}
|
||||
|
||||
spa := web.Handler()
|
||||
spa := web.Handler(s.BrandingCfg)
|
||||
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
|
||||
p := req.URL.Path
|
||||
if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/rest/") {
|
||||
|
||||
+12
-2
@@ -10,6 +10,8 @@ import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
)
|
||||
|
||||
//go:embed all:build
|
||||
@@ -24,7 +26,10 @@ var buildFS embed.FS
|
||||
//
|
||||
// Callers are expected to register this as the router's NotFound/catch-all so
|
||||
// explicitly-routed paths (like /api/* and /rest/*) take precedence.
|
||||
func Handler() http.Handler {
|
||||
//
|
||||
// The branding parameter is applied to index.html once at construction via
|
||||
// html/template; all requests serve the resulting cached bytes.
|
||||
func Handler(branding config.BrandingConfig) http.Handler {
|
||||
sub, err := fs.Sub(buildFS, "build")
|
||||
if err != nil {
|
||||
// fs.Sub on a valid embed path can only fail if the embed directive
|
||||
@@ -32,11 +37,16 @@ func Handler() http.Handler {
|
||||
panic("web: fs.Sub(build) failed: " + err.Error())
|
||||
}
|
||||
|
||||
index, err := fs.ReadFile(sub, "index.html")
|
||||
raw, err := fs.ReadFile(sub, "index.html")
|
||||
if err != nil {
|
||||
panic("web: embedded build/index.html missing: " + err.Error())
|
||||
}
|
||||
|
||||
index, err := applyBrandingTemplate(string(raw), branding)
|
||||
if err != nil {
|
||||
panic("web: branding template failed: " + err.Error())
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.FS(sub))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
)
|
||||
|
||||
// applyBrandingTemplate parses the given HTML source as an html/template,
|
||||
// substitutes the branding fields, and returns the rendered bytes. Used
|
||||
// by Handler at construction time so each request serves cached output
|
||||
// rather than re-rendering. The Vite build (T4) injects the {{ .AppName }}
|
||||
// and similar tokens into the head of build/index.html; in dev (vite dev)
|
||||
// no tokens are present and the template effectively returns the source
|
||||
// unchanged.
|
||||
//
|
||||
// html/template's context-aware escaping handles XSS for both the
|
||||
// HTML-attribute contexts (e.g. <meta content="...">) and the JS-string
|
||||
// context inside <script>window.__MINSTREL__ = { appName: "..." }</script>.
|
||||
func applyBrandingTemplate(htmlSrc string, branding config.BrandingConfig) ([]byte, error) {
|
||||
tpl, err := template.New("index").Parse(htmlSrc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse index template: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, branding); err != nil {
|
||||
return nil, fmt.Errorf("execute index template: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
)
|
||||
|
||||
const sampleIndexHTML = `<!doctype html>
|
||||
<html><head>
|
||||
<title>{{ .AppName }}</title>
|
||||
<meta property="og:title" content="{{ .AppName }}">
|
||||
<meta property="og:description" content="{{ .Description }}">
|
||||
<script>window.__MINSTREL__ = { appName: "{{ .AppName }}", description: "{{ .Description }}" };</script>
|
||||
</head><body></body></html>`
|
||||
|
||||
func TestApplyBrandingTemplate_Defaults(t *testing.T) {
|
||||
out, err := applyBrandingTemplate(sampleIndexHTML, config.BrandingConfig{
|
||||
AppName: "Minstrel",
|
||||
Description: "Self-hosted music server.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
body := string(out)
|
||||
if !strings.Contains(body, "<title>Minstrel</title>") {
|
||||
t.Errorf("missing default title; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBrandingTemplate_CustomBranding(t *testing.T) {
|
||||
out, err := applyBrandingTemplate(sampleIndexHTML, config.BrandingConfig{
|
||||
AppName: "Family Jukebox",
|
||||
Description: "Our home library.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
body := string(out)
|
||||
if !strings.Contains(body, "<title>Family Jukebox</title>") {
|
||||
t.Errorf("custom AppName not in title; body=%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `og:title" content="Family Jukebox"`) {
|
||||
t.Errorf("custom AppName not in og:title; body=%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `appName: "Family Jukebox"`) {
|
||||
t.Errorf("custom AppName not in inline window.__MINSTREL__; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBrandingTemplate_XSSEscape_ScriptContext(t *testing.T) {
|
||||
// Operator config is trusted, but defensive escaping is still required:
|
||||
// an operator who doesn't realize their value is interpolated into HTML
|
||||
// shouldn't be able to accidentally break out of contexts.
|
||||
out, err := applyBrandingTemplate(sampleIndexHTML, config.BrandingConfig{
|
||||
AppName: "</script><img onerror=\"alert(1)\">",
|
||||
Description: "ok",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
body := string(out)
|
||||
|
||||
scriptOpen := strings.Index(body, "<script>window.__MINSTREL__")
|
||||
if scriptOpen < 0 {
|
||||
t.Fatalf("inline script block missing; body=%s", body)
|
||||
}
|
||||
scriptClose := strings.Index(body[scriptOpen:], "</script>")
|
||||
if scriptClose < 0 {
|
||||
t.Fatalf("inline script block has no close; body=%s", body)
|
||||
}
|
||||
scriptBody := body[scriptOpen : scriptOpen+scriptClose]
|
||||
if strings.Contains(scriptBody, "</script>") {
|
||||
t.Errorf("unescaped </script> inside inline-script context: %s", scriptBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBrandingTemplate_XSSEscape_AttrContext(t *testing.T) {
|
||||
out, err := applyBrandingTemplate(sampleIndexHTML, config.BrandingConfig{
|
||||
AppName: `"><img onerror="alert(1)">`,
|
||||
Description: "ok",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
body := string(out)
|
||||
// The unescaped HTML should NOT appear as a live tag in attribute context.
|
||||
if strings.Contains(body, `"><img onerror="alert(1)">`) {
|
||||
t.Errorf("unescaped attribute breakout: %s", body)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user