From 0face10523658543097ad024b8897d627d0df04d Mon Sep 17 00:00:00 2001 From: Ajay D'Souza Date: Tue, 20 Oct 2026 03:32:18 +0000 Subject: [PATCH] Add a strict Content-Security-Policy app-wide A second lock behind the HTML sanitiser for the reader, which prints third-party article markup. Every web page, the installer included, now sends a policy that allows scripts only from this origin or with a per-request nonce: no unsafe-inline, no unsafe-eval, no inline event handlers, javascript: URLs or style attributes, nothing framing the app and forms posting only to it. Stored article images keep their own tighter policy, and the Vite dev server is allowed while it runs. To work under it, Alpine is now its CSP build: the reader and bulk selection are registered components that read their config from data attributes, and the inline onclick/onsubmit/onchange attributes are replaced by data-* hooks handled in resources/js/behaviours.js. The unused Breeze modal and dropdown components are removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014DPvSBmPyHN2QVmXsQQVKe --- README.md | 9 + app/Http/Middleware/ContentSecurityPolicy.php | 90 +++++++++ bootstrap/app.php | 5 +- package-lock.json | 22 +-- package.json | 2 +- resources/css/app.css | 15 ++ resources/js/app.js | 11 +- resources/js/behaviours.js | 43 +++++ resources/js/bulk-selection.js | 51 ++++++ resources/js/reader.js | 20 +- .../views/components/dropdown-link.blade.php | 1 - resources/views/components/dropdown.blade.php | 35 ---- resources/views/components/modal.blade.php | 78 -------- resources/views/install/layout.blade.php | 2 +- resources/views/items/index.blade.php | 21 +-- resources/views/items/partials/row.blade.php | 2 +- resources/views/items/show.blade.php | 33 ++-- resources/views/partials/pwa.blade.php | 2 +- resources/views/save/done.blade.php | 18 +- resources/views/settings/edit.blade.php | 8 +- resources/views/tags/index.blade.php | 8 +- tests/Feature/ContentSecurityPolicyTest.php | 172 ++++++++++++++++++ tests/Feature/Items/ReaderTest.php | 5 +- 23 files changed, 474 insertions(+), 179 deletions(-) create mode 100644 app/Http/Middleware/ContentSecurityPolicy.php create mode 100644 resources/js/behaviours.js create mode 100644 resources/js/bulk-selection.js delete mode 100644 resources/views/components/dropdown-link.blade.php delete mode 100644 resources/views/components/dropdown.blade.php delete mode 100644 resources/views/components/modal.blade.php create mode 100644 tests/Feature/ContentSecurityPolicyTest.php diff --git a/README.md b/README.md index d6ce812..f17ced6 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,15 @@ hostile: checked by content, not by the server's `Content-Type`, and only raster formats are stored; SVG stays remote. Images are served only to the logged-in owner, with `nosniff`. +- **Content-Security-Policy** on every page, as a second lock behind the + sanitiser: scripts only from this origin or carrying the per-request + nonce, no `unsafe-inline` or `unsafe-eval`, no inline event handlers or + style attributes, nothing framing the app, forms posting only here. + Alpine is its CSP build, so components live in `resources/js` rather + than inline in the markup; add behaviour there (or as a `data-*` hook in + `resources/js/behaviours.js`), never as an `onclick` attribute. Images + may come from any `https:` host, because undownloaded article images + stay remote. `npm run dev` is allowed automatically while it runs. - The bookmarklet and share target are `GET` requests, because that is how they arrive. A third-party page could link you to `/save?url=…`; the worst that does is add a link to your own list. diff --git a/app/Http/Middleware/ContentSecurityPolicy.php b/app/Http/Middleware/ContentSecurityPolicy.php new file mode 100644 index 0000000..40c2d05 --- /dev/null +++ b/app/Http/Middleware/ContentSecurityPolicy.php @@ -0,0 +1,90 @@ + tags carrying the + * per-request nonce (Vite::useCspNonce() stamps it on the built assets + * too). No 'unsafe-inline', no 'unsafe-eval': Alpine is the CSP build. + * - Styles and fonts: this origin and the Bunny Fonts stylesheet. No + * inline style attributes. + * - Images: this origin, plus any https host, because article images that + * weren't downloaded keep their remote src. + * - Nothing may frame the app from elsewhere, and forms only post here. + * + * Responses that already carry a policy (stored article images send + * "default-src 'none'") keep their own. + */ +class ContentSecurityPolicy +{ + public const FONT_HOST = 'https://fonts.bunny.net'; + + /** + * Handle an incoming request. + * + * @param Closure(Request): (Response) $next + */ + public function handle(Request $request, Closure $next): Response + { + $nonce = Vite::useCspNonce(); + + $response = $next($request); + + if (! $response->headers->has('Content-Security-Policy')) { + $response->headers->set('Content-Security-Policy', $this->policy($nonce)); + } + + return $response; + } + + protected function policy(string $nonce): string + { + $script = ["'self'", "'nonce-{$nonce}'"]; + $style = ["'self'", self::FONT_HOST]; + $connect = ["'self'"]; + + // `npm run dev`: assets come from the Vite dev server, which also + // injects CSS through