From bf7f55e28aea9c3deca515ebda36e82c09cb9a4b Mon Sep 17 00:00:00 2001 From: Julien Voisin Date: Fri, 3 Oct 2025 04:38:37 +0200 Subject: [PATCH] feat(template): extract CSP to a function, systematically use nonces, and use `default-src 'none'` instead of `self` Having the CSP built in a function instead of in the template makes it easier to properly construct it. This was also the opportunity to switch from default-src 'self' to default-src 'none', to deny everything that isn't explicitly allowed, instead of allowing everything coming from 'self'. Moreover, as Miniflux is shoving the content of feeds in the same origin as itself, using self doesn't do much security-wise. It's much better to systematically use a nonce-based policy, so that an attacker able to bypass the sanitization will have to guess the nonce to gain arbitrary javascript execution. --- internal/template/functions.go | 35 +++++++ internal/template/functions_test.go | 91 +++++++++++++++++++ .../template/templates/common/layout.html | 16 +--- 3 files changed, 131 insertions(+), 11 deletions(-) diff --git a/internal/template/functions.go b/internal/template/functions.go index 59184ded..d7e6e8e6 100644 --- a/internal/template/functions.go +++ b/internal/template/functions.go @@ -33,6 +33,7 @@ type funcMap struct { func (f *funcMap) Map() template.FuncMap { return template.FuncMap{ "contains": strings.Contains, + "csp": csp, "startsWith": strings.HasPrefix, "formatFileSize": formatFileSize, "dict": dict, @@ -116,6 +117,40 @@ func (f *funcMap) Map() template.FuncMap { } } +func csp(user *model.User, nonce string) string { + policies := map[string]string{ + "default-src": "'none'", + "frame-src": "*", + "img-src": "* data:", + "manifest-src": "'self'", + "media-src": "*", + "require-trusted-types-for": "'script'", + "script-src": "'nonce-" + nonce + "' 'strict-dynamic'", + "style-src": "'nonce-" + nonce + "'", + "trusted-types": "html url", + "connect-src": "'self'", + } + + if user != nil { + if user.ExternalFontHosts != "" { + policies["font-src"] = user.ExternalFontHosts + if user.Stylesheet != "" { + policies["style-src"] += " " + user.ExternalFontHosts + } + } + } + + var policy strings.Builder + for key, value := range policies { + policy.WriteString(key) + policy.WriteString(" ") + policy.WriteString(value) + policy.WriteString("; ") + } + + return `` +} + func dict(values ...any) (map[string]any, error) { if len(values)%2 != 0 { return nil, fmt.Errorf("dict expects an even number of arguments") diff --git a/internal/template/functions_test.go b/internal/template/functions_test.go index dd71f28f..0533112b 100644 --- a/internal/template/functions_test.go +++ b/internal/template/functions_test.go @@ -4,10 +4,12 @@ package template // import "miniflux.app/v2/internal/template" import ( + "strings" "testing" "time" "miniflux.app/v2/internal/locale" + "miniflux.app/v2/internal/model" ) func TestDict(t *testing.T) { @@ -159,3 +161,92 @@ func TestFormatFileSize(t *testing.T) { } } } + +func TestCSPExternalFont(t *testing.T) { + want := []string{ + `default-src 'none';`, + `img-src * data:;`, + `media-src *;`, + `frame-src *;`, + `style-src 'nonce-1234';`, + `script-src 'nonce-1234'`, + `'strict-dynamic';`, + `font-src test.com;`, + `require-trusted-types-for 'script';`, + `trusted-types html url;`, + `manifest-src 'self';`, + } + got := csp(&model.User{ExternalFontHosts: "test.com"}, "1234") + + for _, value := range want { + if !strings.Contains(got, value) { + t.Errorf(`Unexpected result, didn't find %q in %q`, value, got) + } + } +} + +func TestCSPNoUser(t *testing.T) { + want := []string{ + `default-src 'none';`, + `img-src * data:;`, + `media-src *;`, + `frame-src *;`, + `style-src 'nonce-1234';`, + `script-src 'nonce-1234'`, + `'strict-dynamic';`, + `require-trusted-types-for 'script';`, + `trusted-types html url;`, + `manifest-src 'self';`, + } + got := csp(nil, "1234") + + for _, value := range want { + if !strings.Contains(got, value) { + t.Errorf(`Unexpected result, didn't find %q in %q`, value, got) + } + } +} + +func TestCSPCustomJSExternalFont(t *testing.T) { + want := []string{ + `default-src 'none';`, + `img-src * data:;`, + `media-src *;`, + `frame-src *;`, + `style-src 'nonce-1234';`, + `script-src 'nonce-1234'`, + `'strict-dynamic';`, + `require-trusted-types-for 'script';`, + `trusted-types html url;`, + `manifest-src 'self';`, + } + got := csp(&model.User{ExternalFontHosts: "test.com", CustomJS: "alert(1)"}, "1234") + + for _, value := range want { + if !strings.Contains(got, value) { + t.Errorf(`Unexpected result, didn't find %q in %q`, value, got) + } + } +} + +func TestCSPExternalFontStylesheet(t *testing.T) { + want := []string{ + `default-src 'none';`, + `img-src * data:;`, + `media-src *;`, + `frame-src *;`, + `style-src 'nonce-1234' test.com;`, + `script-src 'nonce-1234'`, + `'strict-dynamic';`, + `require-trusted-types-for 'script';`, + `trusted-types html url;`, + `manifest-src 'self';`, + } + got := csp(&model.User{ExternalFontHosts: "test.com", Stylesheet: "a {color: red;}"}, "1234") + + for _, value := range want { + if !strings.Contains(got, value) { + t.Errorf(`Unexpected result, didn't find %q in %q`, value, got) + } + } +} diff --git a/internal/template/templates/common/layout.html b/internal/template/templates/common/layout.html index 8c4f069d..ca3529c0 100644 --- a/internal/template/templates/common/layout.html +++ b/internal/template/templates/common/layout.html @@ -25,24 +25,18 @@ - - - {{ if .user }} - {{ $cspNonce := nonce }} - - + {{ $cspNonce := nonce }} + {{ csp .user $cspNonce | safeHTML }} + + + {{ if .user -}} {{ if .user.Stylesheet -}} {{ end -}} - {{ if .user.CustomJS -}} {{ end -}} - {{ else -}} - {{ end -}} - -