Content Security Policy (CSP) is a great option that more people should look into enabling for their websites. Here are my experiences with it on this blog.
Although CSP is not a replacement for proper security because it relies on the browser to respect the policy. It can still help you locate problem points and should be enabled early in development.
I first enabled just the default-src 'self';
directive and soon realized that it doesn't allow inline javascript. This includes both inline event handlers like onclick="alert('hello')"
and inline scripts like <script>window.myData = { foo: 'bar' };</script>
. I was fine with the former since I bind event handlers with the node.addEventListener
method but the latter was a problem. I have several places where I pass data from PHP to JS by json encoding an object e.g.:
<script>window.myData = @php echo json_encode($someData); @endphp;</script>
I could just enable the unsafe inline
option in the CSP header, but that's no good! I then looked around and it looks like the common solution is to use a nonce e.g. default-src 'self' 'nonce-<your-nonce>';
(there's also the option of not using json-encoded global JS variables, but I didn't want to use something like data-
attributes). I first reached around for Laravel's CSRF nonce but I then found out that the Laravel Vite integration makes it easy to add a CSP nonce to all scripts that are added with the @vite()
blade directive. All I had to do was call the Vite::useCspNonce();
method in the middleware that also sets the CSP header and add the 'nonce-".Vite::cspNonce()."'
value to the CSP header.
Now that the nonce is added to the <script>
tags generated by @vite()
I had to add the nonce="..."
attribute to my own <script>
tags that I was using to pass data to JS. I just created a new blade component:
@php
use Illuminate\Support\Facades\Vite;
@endphp
<script nonce="{{ Vite::cspNonce() }}">{{ $slot }}</script>
Then I just replaced my own <script>
tags with <x-script>
, e.g.
<!-- ... -->
<x-script>
window.postCategories = @php echo json_encode($post->categories->toArray()); @endphp;
</x-script>
<!-- ... -->
Another problem that I faced was that the Vite live reload functionality was breaking because CSP was blocking the WebSocket so I added the connect-src 'self' ws://127.0.0.1:5173
directive to the CSP header and that was that.
The laravel-debugbar is a great package that I love using in my local environment (obviously disabled in prod). The package injects its scripts right before the </body>
closing tag but there's no option to add a CSP nonce and it looks like it's a can of worms. I even tried to modify the output myself or use other hacky solutions but I ran into some of the problems that the above comment mentions.
I decided I can take the L and also do the same thing the people in the PR talk about - use the Content-Security-Policy-Report-Only
header for my local environment. That's still a problem because although that allows the debug bar to work, it still spams a lot of warnings in the browser console. I realized that I only use the debug bar to check for N+1 database query problems so I decided to only enable it occasionally when I'm auditing for that.
The self
restriction in the default-src
directive is a bit too restrictive for me so I added a separate image-src
directive for images img-src 'self' data: https://*;
. This allows me to use images from external websites, provided that they use SSL. The data:
part also allows me to use Data URLs.
Enabling CSP via header is as simple in Laravel as adding a new middleware:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Support\Facades\Vite;
class AddContentSecurityPolicyHeaders
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
Vite::useCspNonce();
$header = config('app.env') === 'production' ? 'Content-Security-Policy' : 'Content-Security-Policy-Report-Only';
$defaultSrc = "default-src 'self' 'nonce-".Vite::cspNonce()."'; ";
$imgSrc = "img-src 'self' data: https://*; ";
$connectSrc = "connect-src 'self' ws://127.0.0.1:5173; ";
$baseUri = "base-uri 'self'; ";
$value = $defaultSrc.$imgSrc.$connectSrc.$baseUri;
return $next($request)->withHeaders([
$header => $value,
]);
}
}
I added this middleware to all of my web
routes through the Http Kernel:
<?php
namespace App\Http;
use App\Http\Middleware\AddContentSecurityPolicyHeaders;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
// ...
/**
* The application's route middleware groups.
*
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
// ...
AddContentSecurityPolicyHeaders::class,
],
// ...
];
// ...
}
Update: also added the base-uri
directive, as suggested by the chrome Lighthouse analysis: MDN docs