Sblog

Sblog and CSP

Date: 2022-11-19

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.

Pains with the debugbar package for Laravel

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.

Images

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.

Putting it together

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