From 78871b99e3bd42c013196a46a9aef881067c7e6c Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:46:47 -0400 Subject: [PATCH 1/2] Load every catalogue icon through the server Drop the direct CDN fallbacks from the icon chain, route saved CDN links and icon picker results through /api/icons/cdn, and set connect-src 'self' in both page policies. The demo still loads icons from the CDN directly. --- api/test/nginx-config.test.js | 12 +++++ e2e/icon-privacy.spec.js | 36 +++++++++++++ nginx/csp-default.conf | 2 +- nginx/dashboard.conf | 2 +- ui/admin/index.html | 2 +- ui/index.html | 2 +- ui/js/admin-app-form.js | 23 ++++++--- ui/js/admin-auth.js | 4 +- ui/js/admin-color-control.js | 4 +- ui/js/admin-drag.js | 2 +- ui/js/admin-list.js | 8 +-- ui/js/admin-settings.js | 8 +-- ui/js/admin-shared.js | 4 +- ui/js/admin-widget-form.js | 8 +-- ui/js/admin.js | 20 ++++---- ui/js/background.js | 2 +- ui/js/dashboard.js | 10 ++-- ui/js/icons.js | 61 +++++++++++++--------- ui/js/listbox.js | 2 +- ui/js/spotlight.js | 2 +- ui/js/ui.js | 4 +- ui/js/utils.js | 2 +- ui/js/widget-config-form.js | 8 +-- ui/test/icon-fallback.test.mjs | 10 ++-- ui/test/icons.test.mjs | 92 ++++++++++++++++++++++++++-------- 25 files changed, 227 insertions(+), 103 deletions(-) create mode 100644 e2e/icon-privacy.spec.js diff --git a/api/test/nginx-config.test.js b/api/test/nginx-config.test.js index 124ae50a..dc821a2f 100644 --- a/api/test/nginx-config.test.js +++ b/api/test/nginx-config.test.js @@ -172,6 +172,18 @@ test('every policy in the config states a frame-ancestors', () => { /* 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 lets a page connect to an outside host', () => { + const all = [...dashboard.matchAll(/add_header Content-Security-Policy "([^"]+)"/g)].map(m => m[1]); + all.push(policy); + for (const p of all) { + const connect = p + .split(';') + .map(d => d.trim()) + .find(d => d.startsWith('connect-src ')); + if (connect) assert.equal(connect, "connect-src 'self'"); + } +}); + 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); diff --git a/e2e/icon-privacy.spec.js b/e2e/icon-privacy.spec.js new file mode 100644 index 00000000..5306c64f --- /dev/null +++ b/e2e/icon-privacy.spec.js @@ -0,0 +1,36 @@ +// @ts-check +/* Icons from the catalogues load through the server. The browser never asks + the CDN, even when the server cannot supply a file. */ + +const { test, expect } = require('@playwright/test'); +const { seedConfig, dismissSetupPrompt, app, openDashboardList } = require('./helpers'); + +const ITEMS = [ + { ...app('unknown', 'Unknown'), iconUrl: 'no-such-icon-in-any-catalogue' }, + { ...app('linked', 'Linked'), iconUrl: 'https://cdn.jsdelivr.net/gh/selfhst/icons/svg/jellyfin-light.svg' }, +]; + +test.beforeEach(async ({ request }) => { + await seedConfig(request, { items: ITEMS }); + await dismissSetupPrompt(request); +}); + +for (const [path, open] of [ + ['/', page => page.goto('/')], + ['/admin/', openDashboardList], +]) { + test(`${path} never requests an icon from the CDN`, async ({ page }) => { + const outside = []; + page.on('request', r => { + if (new URL(r.url()).host === 'cdn.jsdelivr.net') outside.push(r.url()); + }); + const proxied = []; + page.on('response', r => { + if (r.url().includes('/api/icons/cdn?')) proxied.push(new URL(r.url()).searchParams.get('name')); + }); + await open(page); + await expect.poll(() => [...new Set(proxied)].sort()).toEqual(['jellyfin-light', 'no-such-icon-in-any-catalogue']); + await page.waitForLoadState('networkidle'); + expect(outside).toEqual([]); + }); +} diff --git a/nginx/csp-default.conf b/nginx/csp-default.conf index 856732bb..695fd4ec 100644 --- a/nginx/csp-default.conf +++ b/nginx/csp-default.conf @@ -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'; 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; +add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https://images.unsplash.com https://cdn.jsdelivr.net; frame-src 'self'; frame-ancestors 'self'; connect-src 'self'; font-src 'self';" always; diff --git a/nginx/dashboard.conf b/nginx/dashboard.conf index 99102420..ecc4ab1c 100644 --- a/nginx/dashboard.conf +++ b/nginx/dashboard.conf @@ -112,7 +112,7 @@ server { include /etc/nginx/http.d/security-headers.conf; add_header X-Frame-Options "DENY" always; add_header Cache-Control "no-cache, no-store, must-revalidate" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https://images.unsplash.com https://cdn.jsdelivr.net; connect-src 'self' https://cdn.jsdelivr.net; font-src 'self'; frame-ancestors 'none';" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https://images.unsplash.com https://cdn.jsdelivr.net; connect-src 'self'; font-src 'self'; frame-ancestors 'none';" always; } # ── Icons ── diff --git a/ui/admin/index.html b/ui/admin/index.html index 99c0825d..52f27f5c 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -300,6 +300,6 @@