diff --git a/CHANGELOG.md b/CHANGELOG.md index 87468ffe..b3ae726d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Pages no longer permit stylesheets or fonts from Google Fonts, which nothing + requests. + - The `linux/arm64` release image is scanned for vulnerabilities before it is published, the same as `linux/amd64`. diff --git a/api/test/nginx-config.test.js b/api/test/nginx-config.test.js index 3ae203b6..2b58ccc1 100644 --- a/api/test/nginx-config.test.js +++ b/api/test/nginx-config.test.js @@ -110,6 +110,33 @@ test('every policy in the config states a frame-ancestors', () => { } }); +/* ── the hosts a page may reach ───────────────────────────────────────────── */ + +/* Google Fonts was allowed by three policies for a widget that no longer + exists, so every page could pull a stylesheet and a font from a host nothing + asked for. The app uses system fonts and ships its own assets. */ +test('no policy allows an outside font or stylesheet host', () => { + const all = [...dashboard.matchAll(/add_header Content-Security-Policy "([^"]+)"/g)].map(m => m[1]); + all.push(policy); + for (const p of all) { + const directives = Object.fromEntries( + p + .split(';') + .map(d => d.trim()) + .filter(Boolean) + .map(d => { + const [name, ...values] = d.split(/\s+/); + return [name, values]; + }), + ); + for (const name of ['font-src', 'style-src']) { + const values = directives[name] || []; + const hosts = values.filter(v => v.startsWith('http') || v.includes('.')); + assert.deepEqual(hosts, [], `${name} allows an outside host: ${hosts.join(' ')}`); + } + } +}); + /* ── P16-5 and P16-6: version disclosure and compression ──────────────────── */ test('the nginx version is not advertised', () => { diff --git a/nginx/csp-default.conf b/nginx/csp-default.conf index f95664a4..79a888e4 100644 --- a/nginx/csp-default.conf +++ b/nginx/csp-default.conf @@ -3,7 +3,7 @@ # this file. # # Two locations set their own policy inline instead: -# /admin no Google Fonts, no frame-src +# /admin no frame-src # /widgets/ allows 'unsafe-inline' scripts # # frame-ancestors says who may frame us, frame-src what we may frame. @@ -12,4 +12,4 @@ # # Keep this to the one header. Anything that differs per call site belongs in # the location. -add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https://images.unsplash.com https://cdn.jsdelivr.net; frame-src 'self'; frame-ancestors 'self'; connect-src 'self' https://api.unsplash.com https://cdn.jsdelivr.net; font-src 'self' https://fonts.gstatic.com;" always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://images.unsplash.com https://cdn.jsdelivr.net; frame-src 'self'; frame-ancestors 'self'; connect-src 'self' https://api.unsplash.com https://cdn.jsdelivr.net; font-src 'self';" always; diff --git a/nginx/dashboard.conf b/nginx/dashboard.conf index 60aa4f8e..3f590037 100644 --- a/nginx/dashboard.conf +++ b/nginx/dashboard.conf @@ -113,7 +113,7 @@ server { include /etc/nginx/http.d/security-headers.conf; add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header X-Frame-Options "" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self'; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; frame-ancestors 'self';" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self'; font-src 'self'; frame-ancestors 'self';" always; } # ── API proxy ──