diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bc1db2b5..9f26949c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2127,3 +2127,21 @@ func TestParseConfigDumpOutput(t *testing.T) { t.Fatal(err) } } + +func TestContentSecurityPolicy(t *testing.T) { + os.Clearenv() + os.Setenv("CONTENT_SECURITY_POLICY", "default-src 'self' fonts.googleapis.com fonts.gstatic.com; img-src * data:; media-src *; frame-src *; style-src 'self' fonts.googleapis.com fonts.gstatic.com 'nonce-%s'") + + parser := NewParser() + opts, err := parser.ParseEnvironmentVariables() + if err != nil { + t.Fatalf(`Parsing failure: %v`, err) + } + + expected := "default-src 'self' fonts.googleapis.com fonts.gstatic.com; img-src * data:; media-src *; frame-src *; style-src 'self' fonts.googleapis.com fonts.gstatic.com 'nonce-%s'" + result := opts.ContentSecurityPolicy() + + if result != expected { + t.Fatalf(`Unexpected CONTENT_SECURITY_POLICY value, got %v instead of %v`, result, expected) + } +} diff --git a/internal/config/options.go b/internal/config/options.go index d5d793ac..4078ac55 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -86,6 +86,7 @@ const ( defaultWatchdog = true defaultInvidiousInstance = "yewtu.be" defaultWebAuthn = false + defaultContentSecurityPolicy = "default-src 'self'; img-src * data:; media-src *; frame-src *; style-src 'self' 'nonce-%s'; require-trusted-types-for 'script'; trusted-types ttpolicy;" ) var defaultHTTPClientUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)" @@ -171,6 +172,7 @@ type Options struct { invidiousInstance string mediaProxyPrivateKey []byte webAuthn bool + contentSecurityPolicy string } // NewOptions returns Options with default values. @@ -247,6 +249,7 @@ func NewOptions() *Options { invidiousInstance: defaultInvidiousInstance, mediaProxyPrivateKey: crypto.GenerateRandomBytes(16), webAuthn: defaultWebAuthn, + contentSecurityPolicy: defaultContentSecurityPolicy, } } @@ -629,6 +632,11 @@ func (o *Options) FilterEntryMaxAgeDays() int { return o.filterEntryMaxAgeDays } +// ContentSecurityPolicy returns value for Content-Security-Policy meta tag. +func (o *Options) ContentSecurityPolicy() string { + return o.contentSecurityPolicy +} + // SortedOptions returns options as a list of key value pairs, sorted by keys. func (o *Options) SortedOptions(redactSecret bool) []*Option { var keyValues = map[string]interface{}{ @@ -707,6 +715,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option { "WORKER_POOL_SIZE": o.workerPoolSize, "YOUTUBE_EMBED_URL_OVERRIDE": o.youTubeEmbedUrlOverride, "WEBAUTHN": o.webAuthn, + "CONTENT_SECURITY_POLICY": o.contentSecurityPolicy, } keys := make([]string, 0, len(keyValues)) diff --git a/internal/config/parser.go b/internal/config/parser.go index f7e58aaa..8c27c098 100644 --- a/internal/config/parser.go +++ b/internal/config/parser.go @@ -273,6 +273,8 @@ func (p *Parser) parseLines(lines []string) (err error) { p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance) case "WEBAUTHN": p.opts.webAuthn = parseBool(value, defaultWebAuthn) + case "CONTENT_SECURITY_POLICY": + p.opts.contentSecurityPolicy = parseString(value, defaultContentSecurityPolicy) } } diff --git a/internal/template/templates/common/layout.html b/internal/template/templates/common/layout.html index c8579ed0..7f6ffa38 100644 --- a/internal/template/templates/common/layout.html +++ b/internal/template/templates/common/layout.html @@ -36,8 +36,13 @@ {{ if and .user .user.Stylesheet }} {{ $stylesheetNonce := nonce }} - - + {{ $containsNonce := contains .contentSecurityPolicy "nonce-%s" }} + {{ if $containsNonce }} + {{ noescape ( printf "" (printf .contentSecurityPolicy $stylesheetNonce ) ) }} + {{ else }} + {{ noescape ( printf "" .contentSecurityPolicy ) }} + {{ end }} + {{ else }} {{ end }} @@ -58,7 +63,6 @@ data-webauthn-delete-all-url="{{ route "webauthnDeleteAll" }}" {{ end }} {{ if .user }}{{ if not .user.KeyboardShortcuts }}data-disable-keyboard-shortcuts="true"{{ end }}{{ end }}> - {{ if .user }} {{ t "skip_to_content" }}
diff --git a/internal/ui/view/view.go b/internal/ui/view/view.go index 742bb3a9..a0480388 100644 --- a/internal/ui/view/view.go +++ b/internal/ui/view/view.go @@ -46,5 +46,6 @@ func New(tpl *template.Engine, r *http.Request, sess *session.Session) *View { "sw_js_checksum": static.JavascriptBundleChecksums["service-worker"], "webauthn_js_checksum": static.JavascriptBundleChecksums["webauthn"], "webAuthnEnabled": config.Opts.WebAuthn(), + "contentSecurityPolicy": config.Opts.ContentSecurityPolicy(), }} } diff --git a/miniflux.1 b/miniflux.1 index d0879f09..e193abc9 100644 --- a/miniflux.1 +++ b/miniflux.1 @@ -540,6 +540,15 @@ Default is 16 workers\&. YouTube URL which will be used for embeds\&. .br Default is https://www.youtube-nocookie.com/embed/\&. +.TP +.B CONTENT_SECURITY_POLICY +Set custom value for Content-Security-Policy meta tag. Used when custom CSS is applied. +.br +It may contain "nonce-%s", where nonce will be placed\&. +.br +Default is "default-src 'self'; img-src * data:; media-src *; frame-src *; style-src 'self' 'nonce-%s'; require-trusted-types-for 'script'; trusted-types ttpolicy;"\&. +.TP + .SH AUTHORS .P Miniflux is written and maintained by Fr\['e]d\['e]ric Guillot\&.