1
0
Fork 0
mirror of https://github.com/miniflux/v2.git synced 2025-09-15 18:57:04 +00:00

refactor(template): extract the CSP in a function and systematically use nonces.

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.

While the merge-request has been tested locally, it would still be prudent to
thoroughly test it before merging, as it has the potential to break the
user-interface should weird constructs be used.
This commit is contained in:
jvoisin 2025-08-22 14:05:10 +02:00
parent eb22d90b56
commit 65b86b1bcc
3 changed files with 51 additions and 11 deletions

View file

@ -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,42 @@ func (f *funcMap) Map() template.FuncMap {
}
}
func csp(user *model.User, nonce string) string {
order := [...]string{"default-src", "img-src", "media-src", "frame-src", "style-src", "script-src", "font-src", "require-trusted-types-for", "trusted-types"}
policies := map[string]string{
"default-src": "'none'",
"img-src": "* data:",
"media-src": "*",
"frame-src": "*",
"style-src": "'nonce-" + nonce + "'",
"script-src": "'nonce-" + nonce + "' 'strict-dynamic'",
"require-trusted-types-for": "'script'",
"trusted-types": "html url",
}
if user != nil {
if user.ExternalFontHosts != "" {
policies["font-src"] = user.ExternalFontHosts
if user.Stylesheet != "" {
policies["style-src"] += " " + user.ExternalFontHosts
}
}
}
var policy strings.Builder
// This is needed to always have the same order.
for _, key := range order {
if value, ok := policies[key]; ok {
policy.WriteString(key)
policy.WriteString(" ")
policy.WriteString(value)
policy.WriteString("; ")
}
}
return `<meta http-equiv="Content-Security-Policy" content="` + policy.String() + `">`
}
func dict(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict expects an even number of arguments")

View file

@ -8,6 +8,7 @@ import (
"time"
"miniflux.app/v2/internal/locale"
"miniflux.app/v2/internal/model"
)
func TestDict(t *testing.T) {
@ -159,3 +160,11 @@ func TestFormatFileSize(t *testing.T) {
}
}
}
func TestCSP(t *testing.T) {
want := `<meta http-equiv="Content-Security-Policy" content="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; ">`
got := csp(&model.User{ExternalFontHosts: "test.com"}, "1234")
if got != want {
t.Errorf(`Unexpected result, got %q instead of %q`, got, want)
}
}

View file

@ -25,24 +25,18 @@
<link rel="apple-touch-icon" sizes="167x167" href="{{ route "appIcon" "filename" "icon-167.png" }}">
<link rel="apple-touch-icon" sizes="180x180" href="{{ route "appIcon" "filename" "icon-180.png" }}">
<link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" .theme "checksum" .theme_checksum }}">
{{ if .user }}
{{ $cspNonce := nonce }}
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; {{ if .user.ExternalFontHosts }}font-src {{ .user.ExternalFontHosts }}; {{ end }}style-src 'self'{{ if .user.Stylesheet }}{{ if .user.ExternalFontHosts }} {{ .user.ExternalFontHosts }}{{ end }} 'nonce-{{ $cspNonce }}'{{ end }}{{ if .user.CustomJS }}; script-src 'self' 'nonce-{{ $cspNonce }}'{{ end }}; require-trusted-types-for 'script'; trusted-types html url;">
{{ $cspNonce := nonce }}
{{ csp .user $cspNonce | safeHTML }}
<link rel="stylesheet" nonce="{{ $cspNonce }}" type="text/css" href="{{ route "stylesheet" "name" .theme "checksum" .theme_checksum }}">
<script nonce="{{ $cspNonce }}" src="{{ route "javascript" "name" "app" "checksum" .app_js_checksum }}" type="module"></script>
{{ if .user -}}
{{ if .user.Stylesheet -}}
<style nonce="{{ $cspNonce }}">{{ .user.Stylesheet | safeCSS }}</style>
{{ end -}}
{{ if .user.CustomJS -}}
<script type="module" nonce="{{ $cspNonce }}">{{ .user.CustomJS | safeJS }}</script>
{{ end -}}
{{ else -}}
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; require-trusted-types-for 'script'; trusted-types html url;">
{{ end -}}
<script src="{{ route "javascript" "name" "app" "checksum" .app_js_checksum }}" type="module"></script>
</head>
<body
data-service-worker-url="{{ route "javascript" "name" "service-worker" "checksum" .sw_js_checksum }}"