Intro

Events In Plain Sight has user generated content. It is actually kinda based around it. And that content is often HTML which is just begging for nefarious uses so a Content Security Policy (CSP) is pretty necessary. I mean, you should have one anyways as a Best Practice[tm], but EiPS and things like it really need one.

Content Security Policy

Because of course they do, Spatie has a package which does the majority of the heavy lifting. I looked at a couple others, but liked how you configure policies per ‘thing’ which then get aggregated up. The last time I had to do this it was a horrible loop of change-in-cloudfront-wait-for-propagation-find-next-problem.

Installing was pretty easy, per the repo’s readme

composer require spatie/laravel-csp
php artisan vendor:publish --tag=csp-config

And then injecting the headers to every page via a middleware in bootstrap\app.php.

use Spatie\Csp\AddCspHeaders;

         $middleware->web(append: [
             \App\Http\Middleware\OptionalAuthenticate::class,
             AddCspHeaders::class,
         ]);

Enabling the services I am using was easy and why I chose this package.

    'presets' => [
        Spatie\Csp\Presets\Basic::class,
        Spatie\Csp\Presets\GoogleFonts::class,
        Spatie\Csp\Presets\GoogleRecaptcha::class,
        Spatie\Csp\Presets\GoogleTagManager::class,
        Spatie\Csp\Presets\Stripe::class,
    ],

Vite

Because I’m using Vite to build my assets, there is an extra headaches.

Essentially, we need to teach the package which nonce to use in the headers, and we need to teach Vite itself what to inject thanks to the Laravel/Vite integration.

First we need to tell the Blade rendering engine, via a middleware, that we want a nonce. And what it is.

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;

class GenerateCspNonce
{
    /**
     * Handle an incoming request.
     *
     * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
     */
    public function handle(Request $request, Closure $next): Response
    {
        Vite::useCspNonce();

        return $next($request);
    }
}

Then we create a custom nonce generator for the package so it will use the nonce we created in the middleware (and put the path in config/csp.php.)

namespace App\Support;

use Illuminate\Support\Facades\Vite;
use Spatie\Csp\Nonce\NonceGenerator;

class LaravelViteNonceGenerator implements NonceGenerator
{
    public function generate(): string
    {
        return Vite::cspNonce();
    }
}

Don’t forget to add it to the web middleware group

        $middleware->web(append: [
            \App\Http\Middleware\OptionalAuthenticate::class,
            \App\Http\Middleware\GenerateCspNonce::class,
            AddCspHeaders::class,
        ]);

Note that the GenerateCspNonce middleware is before the AddCspHeaders one. If you flip it, then you have to flip what Vite function you use. Vite::useCspNonce() creates a new nonce, and Vite::cspNonce() fetches it. Get the order wrong, and you’ll have nonce failures … correctly.

With all that inplace, I hit reload waiting for things to break … except they didn’t at first. Why? Because the package doesn’t add things if you are using Vite to hot load things. Allegedly, the config value CSP_ENABLED_WHILE_HOT_RELOADING (default is false) should make it work. Buuuut, I couldn’t get it to work. So I stopped that and just built the assets using npm run build which is a pain. I’ll have to figure that out later. I suspect its a Sail problem. I hate not having CSP problem be exposed during local dev. In theory getting browser tests running in CI will help, but that is a problem for later.

Anyhow.

Finally, things broke in the app. As they should as 3rd parties that shouldn’t be there were exposed; unused but loaded fonts, leftover bootstrap scss, etc. were all removed.

One other place that needed to be updated were the leftover <script> tags in places for some event listeners, etc. that haven’t been converted to Livewire/Alpine. Each of those needed to be updated to include the nonce.

<script @cspNonce>

Recaptcha

Bots being bots, getting fake users is a constant thing. Recaptcha is one way of dealing with this. I’m using Laravel Recaptcha V3. This was all working last week … and of course CSP broke it.

This is the preset from the CSP pacakage for recaptcha

        $policy
            ->add(Directive::SCRIPT, ['www.google.com/recaptcha/', 'www.gstatic.com/recaptcha/'])
            ->add(Directive::FRAME, ['www.google.com/recaptcha/', 'recaptcha.google.com/recaptcha/']);

Unfortunately, it also need a ‘connect’ policy too. So we need to create a custom preset. I’m just going copy/paste/update but could have just make a single directive preset and add it to the list as well.

namespace App\Support;

use Spatie\Csp\Directive;
use Spatie\Csp\Policy;
use Spatie\Csp\Preset;

class GoogleRecaptchaCSPPreset implements Preset
{
    public function configure(Policy $policy): void
    {
        $policy
            ->add(Directive::CONNECT, ['www.google.com/recaptcha/'])
            ->add(Directive::SCRIPT, ['www.google.com/recaptcha/', 'www.gstatic.com/recaptcha/'])
            ->add(Directive::FRAME, ['www.google.com/recaptcha/', 'recaptcha.google.com/recaptcha/']);
    }
}
    'presets' => [
        Spatie\Csp\Presets\Basic::class,
        Spatie\Csp\Presets\GoogleFonts::class,
        App\Support\GoogleRecaptchaCSPPreset::class,
        Spatie\Csp\Presets\GoogleTagManager::class,
        Spatie\Csp\Presets\GoogleAnalytics::class,
        Spatie\Csp\Presets\Stripe::class,
    ],

That gets most of the bits loaded, but then, when you inject the actual bits into the blade file

{!! RecaptchaV3::field('signup') !!}

you end up with an inline script

<script>
  grecaptcha.ready(function() {
      grecaptcha.execute('6LcUTVYsAAAAAIoYcHKlwLb40QBYnBUdVUAk1HbQ', {action: 'signup'}).then(function(token) {
         document.getElementById('g-recaptcha-response-697a3f2a36a19').value = token;
      });
  });
  </script>

which does not have a nonce, and therefore gets rejected. So, we’re forking the project to add them. Yay for named parameters in PHP 8.x. (I’ll create a PR later.)

Add the nonce to the init injection in the layout.

{!! RecaptchaV3::initJs(nonce: Vite::cspNonce()) !!}

and then then the actual registration form

{!! RecaptchaV3::field('signup', nonce: Vite::cspNonce()) !!}

And with that we’re back in action with Recaptcha and CSP.

Laravel Debugbar

Know what else operates via injected javascript in our Blade files? That’s right. Laravel Debugbar does. Which means our CSP policy blocks it too. Maybe this is why the Vite reload stuff is disabled by default…

Some searching led to a solution that likely worked prior to the cleanup of how Laravel boots, but we it still works … one just in a different place. What we’re going to do is create a new Middleware to inject the nonce in.

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Vite;

class LaravelDebugBarNonceMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        if (app()->bound('debugbar')) {
            $debugbar = app(\Fruitcake\LaravelDebugbar\LaravelDebugbar::class);
            $renderer = $debugbar->getJavascriptRenderer();
            $renderer->setCspNonce(Vite::cspNonce());
        }

        return $next($request);
    }
}

This Middleware needs to be run after the GenerateCspNonce one we created above (because Vite::useCspNonce() needs to have run.) Reload the page and the debugbar is back.

LaunchList

I currently use LaunchList to build a mailing list. Which … of course is JS. So we need a new Preset.

namespace App\Support;

use Spatie\Csp\Directive;
use Spatie\Csp\Policy;
use Spatie\Csp\Preset;

class LaunchListCSPPreset implements Preset
{
    public function configure(Policy $policy): void
    {
        $policy
            ->add(Directive::SCRIPT, ['getlaunchlist.com'])
            ->add(Directive::FRAME, ['getlaunchlist.com']);
    }
}

And add it to the list in config\csp.php.

Livewire / Alpine

Was removing Vue to use Livewire / Alpine the right decision? Absolutely.

But, of course, they have issues with CSP. (I mean, obvs.) It does look like Livewire 4 has a csp safe_mode. And of course, 4.x came out a week after I implemented 3.x so I guess I’m upgrading.

It mostly worked as advertised. Setting csp_safe to true per the docs did most of the heavy lifting. There were a few violations remaining though. Livewire adds the nonce into tags it injects, which it does on every page automatically that has a Livewire component.

Alpine on pages without Livewire

There are some pages though where I need Alpine and not Livewire. Because there is no Livewire component on the pages, you need to inject Livewire (and consequently, Alpine) by hand.

Normally it is just @livewireStyles and @livewireScripts.

However, in order to get that to work with CSP, you do

@livewireStyles(['nonce' => Vite::cspNonce()])

in the <head> and in at the end of the <body> you place

@livewireScripts(['nonce' => Vite::cspNonce()])

And then clean up the actual offending bits.

Logout

The main one was how logout was being handled. onclick doesn’t work unless you have ‘unsafe-inline’ in your policy, which negates a lot of the policy.

This is how logout from the top-nav was originally constructed.

<form method="POST" action="">
    @csrf
    <x-dropdown-link :href="route('logout')"
                     onclick="event.preventDefault(); this.closest('form').submit();">
        
    </x-dropdown-link>
</form>

To fixe this, I had to move the onclick stuff a separate script with the csp nonce included.

<form x-data method="POST" action="" @submit.prevent="submitForm" >
    @csrf
    <x-dropdown-link x-data="logoutLink" @click="clickHandler($el)" id="logout">
        
    </x-dropdown-link>
</form>

<script @cspNonce>
    document.addEventListener('alpine:init', () => {
        Alpine.data('logoutLink', () => ({
            clickHandler(element) {
                element.closest('form').submit()
            },
        }));
    });
</script>

closest() is actually a pretty neat little function. But note that that closest is ‘up the DOM’ and not how we, as humans, might think about how that term is defined.

We will not discuss how long that took me to figure out this solution, nor the fact I was editing the wrong file which is partially why it didn’t work. Needless to say, that section has been turned into an anonymous blade component.

User self-deletion also needed some similar love, but if we’re honest, I think that might have been a Tailwind upgrade earlier that broke then.

CDN

An classic-for-a-reason performance cheat is load assets from a different host than your main content. Even if it is the same server. This is because there is a limit of 6 concurrent connections per host in today’s browsers. (It used to be way lower.)

But this means we also need another Preset.

namespace App\Support;

use Spatie\Csp\Directive;
use Spatie\Csp\Policy;
use Spatie\Csp\Preset;

class CDNCSPPreset implements Preset
{
    public function configure(Policy $policy): void
    {
        $policy
            ->add(Directive::IMG, ['cdn.eventsinplainsight.com']);
    }
}

Google Tag Manager

Last week I got Google Tag Manager working using the spatie/laravel-googletagmanager package. It works via injecting not only the GTM js, but some of its own. Via <script> tags. Which, of course, broke CSP. So I forked the project, banged together a PR and sent it upstream. In under 24 hours they had improved the code and merged it. So if you want this combo to work, you need get at least version 3.1.0 of the package and set the nonceEnabled value to true in the config.

(It is so obvious you can use @if in the middle of a tag to set attributes now that I see it. I’ve always done blocks. Guess I’ve got another thing to sweep through my project for.)

Outro

Security is always a trade-off. Usually with the sacrifice of convience. This only sacrificed 4 days of time for a ‘simple’ task of adding a package. But this is so much easier a way to do it than modifying the web server or cdn headers. Sprinkle apps FTW!

And yes, AI might have been able to do this. But by doing this by hand I fully understand what happened, and why. (And improved by Livewire/Alpine skill.) Knowing how / why things are the way they are is important when you are selling a product. ‘Sorry, the AI is down’ or ‘I ran out of tokens for this morning’ doesn’t fly with people paying for your product.