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