From bfb7857bc3449210cc24d2f98caebe9a7ddd17c0 Mon Sep 17 00:00:00 2001 From: Jason Shellen Date: Wed, 26 Aug 2026 18:21:11 -0700 Subject: [PATCH] Ship a Content-Security-Policy on the viewer and Tauri placeholder Second layer behind DOMPurify for untrusted article HTML. Fixes #117: - The viewer HTML response now carries a CSP: scripts/styles self+inline (everything is inlined by embed-viewer.ts), images/media/connect open for arbitrary article assets and HLS streams, frames limited to YouTube embeds, object-src none - tauri.conf.json csp covers the tauri-served placeholder page (app windows load the HTTP server, which sends the header itself) Verified against the real viewer in Chromium: article with 10 remote images renders with zero CSP violations. Source-shape tests lock in the load-bearing directives. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AdXEsWQTadb9qbTQjLkMqn --- src-tauri/tauri.conf.json | 2 +- src/viewer.test.ts | 20 ++++++++++++++++++++ src/viewer.ts | 13 ++++++++++++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e615814..21a5516 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -12,7 +12,7 @@ "withGlobalTauri": true, "windows": [], "security": { - "csp": null + "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" } }, "bundle": { diff --git a/src/viewer.test.ts b/src/viewer.test.ts index e660675..0fcba64 100644 --- a/src/viewer.test.ts +++ b/src/viewer.test.ts @@ -318,6 +318,26 @@ bookmarked: 2025-01-15T00:00:00Z }); }); +describe('content security policy (#117)', () => { + const viewerSource = readFileSync(join(__dirname, 'viewer.ts'), 'utf-8'); + + test('viewer HTML ships a Content-Security-Policy', () => { + expect(viewerSource).toContain("'Content-Security-Policy':"); + // The load-bearing directives: no remote scripts, no plugins, no foreign frames beyond YouTube. + expect(viewerSource).toContain("script-src 'self' 'unsafe-inline'"); + expect(viewerSource).toContain("object-src 'none'"); + expect(viewerSource).toContain('frame-src https://www.youtube.com'); + // Article assets must stay unrestricted or every remote image/podcast breaks. + expect(viewerSource).toContain('img-src * data: blob:'); + expect(viewerSource).toContain('media-src * data: blob:'); + }); + + test('tauri.conf.json no longer ships csp: null', () => { + const conf = JSON.parse(readFileSync(join(__dirname, '..', 'src-tauri', 'tauri.conf.json'), 'utf-8')); + expect(conf.app.security.csp).toBeTruthy(); + }); +}); + describe('sync progress', () => { const rootDir = join(__dirname, '..'); diff --git a/src/viewer.ts b/src/viewer.ts index 1e6b2a1..a5329de 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -857,7 +857,18 @@ export function startViewer(initialOutputPath: string, port = 7777, openBrowser } if (url.pathname === '/' || url.pathname === '/index.html') { - res.writeHead(200, { 'Content-Type': 'text/html' }); + // Second layer behind DOMPurify (#117): article content is untrusted + // remote HTML. Scripts/styles are fully inlined by embed-viewer.ts, so + // script-src needs no remote hosts; images/media/connect stay open for + // arbitrary article assets and HLS; frames are YouTube-embed only. + res.writeHead(200, { + 'Content-Type': 'text/html', + 'Content-Security-Policy': + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " + + "img-src * data: blob:; media-src * data: blob:; font-src 'self' data:; connect-src *; " + + "frame-src https://www.youtube.com https://www.youtube-nocookie.com; " + + "worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'", + }); res.end(VIEWER_HTML); return; }