Laravel: Improving Application Security with CSP

Content Security Policy (CSP) is a useful way to improve Laravel application security. It lets you allowlist the sources of scripts, styles and other resources a page can load. This helps prevent attackers from injecting malicious code into a view and therefore into visitors' browsers. It can also provide greater confidence that third-party resources are the ones you intended to use.

This article explains what Content Security Policy is and what it offers. We will then use the spatie/laravel-csp package to add CSP to a Laravel application. We will also cover a few ways to make introducing CSP into an existing application easier.

What is Content Security Policy?

CSP is a set of rules normally returned by your server to the browser in the Content-Security-Policy response header. It lets developers specify which resources a browser may load.

This helps ensure visitors load only the images, fonts, styles and scripts you consider safe. If the browser tries to load a prohibited resource, it blocks it.

A well-configured Content Security Policy can reduce the risk of user data theft and other malicious activity carried out through attacks such as cross-site scripting (XSS).

CSP can become complex, particularly in large applications, but it is an important part of application security.

Implementing CSP in Laravel

As mentioned, CSP is a set of rules sent in a response header or sometimes defined in an HTML <meta> tag. There are several ways to apply it. You could configure headers in a server such as Nginx, but that can become cumbersome to maintain. I find managing the policy at the application level easier.

The simplest way to add CSP to Laravel is usually the spatie/laravel-csp package. Let's explore how to use it and the options it provides.

Installation

Install spatie/laravel-csp through Composer:

composer require spatie/laravel-csp

Then publish the package's configuration file:

php artisan vendor:publish --tag=csp-config

This should create config/csp.php.

Applying a policy to responses

Now that the package is installed, we need to add the Content-Security-Policy header to HTTP responses. There are several ways to do this, depending on your application.

To apply CSP to all web routes, add Spatie\Csp\AddCspHeaders to the web section of $middlewareGroups in app/Http/Kernel.php:

// ...

protected $middlewareGroups = [
'web' => [
// ...
\Spatie\Csp\AddCspHeaders::class,
],

// ...

Every route using the web middleware group will then receive a CSP header automatically.

To apply CSP to individual routes or route groups instead, use the middleware in web.php. For a single route, this might look like:

use Spatie\Csp\AddCspHeaders;

Route::get('example-route', 'ExampleController')->middleware(AddCspHeaders::class);

For a route group, use:

use Spatie\Csp\AddCspHeaders;

Route::middleware(AddCspHeaders::class)->group(function () {
// Здесь задаём маршруты...
});

If you do not explicitly specify a policy for the middleware, it uses the one under the default key in config/csp.php. Update that value if you want your own default policy.

An application may need multiple policies. For example, public pages and restricted sections may use different scripts, styles or fonts and therefore require different CSP rules.

To specify a policy explicitly for a route, use:

use App\Support\Csp\Policies\CustomPolicy;
use Spatie\Csp\AddCspHeaders;

Route::get('example-route', 'ExampleController')->middleware(AddCspHeaders::class.':'.CustomPolicy::class);

You can specify a policy for a route group in the same way:

use App\Support\Csp\Policies\CustomPolicy;
use Spatie\Csp\AddCspHeaders;

Route::middleware(AddCspHeaders::class.':'.CustomPolicy::class)->group(function () {
// Здесь задаём маршруты...
});

Using the default Content Security Policy

The package includes Spatie\Csp\Policies\Basic, which defines several rules. It allows images, fonts, styles and scripts only from the same domain as the application. If all your resources come from your own domain, this may be sufficient.

The Basic policy generates a Content-Security-Policy header like this:

base-uri 'self';connect-src 'self';default-src 'self';form-action 'self';img-src 'self';media-src 'self';object-src 'none';script-src 'self' 'nonce-YKXiTcrg6o4DuumXQDxYRv9gHPlZng6z';style-src 'self' 'nonce-YKXiTcrg6o4DuumXQDxYRv9gHPlZng6z'

Creating a custom Content Security Policy

Depending on your application, you may need a custom policy that allows resources beyond those permitted by Basic.

CSP offers many rules, which can quickly become complex. To get started, let's look at some common rules you will probably use in your own application.

For this tutorial, assume a page uses these resources:

  • A local JavaScript file at /js/app.js.
  • An external JavaScript file at https://unpkg.com/vue@3/dist/vue.global.js.
  • Inline JavaScript, but only the inline code we explicitly authorize.
  • A local CSS file at /css/app.css.
  • An external CSS file at https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css.
  • A local image at /img/hero.png.
  • An external image at https://laravel.com/img/logotype.min.svg.

Let's create a policy that allows only these resources. If the browser requests anything else, the request will be blocked and the resource will not load.

The page's basic Blade view might look like this:

<html>
<head>
<title>CSP Test</title>

{{-- Загружаем Vue.js из CDN --}}
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

{{-- Загружаем JS сценарий с нашего домена --}}
<script src="{{ asset('js/app.js') }}"></script>

{{-- Загружаем Bootstrap 5 CSS --}}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD"
crossorigin="anonymous"
>

{{-- Загружаем CSS файл с нашего домена --}}
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
</head>

<body>
<h1>Csp Header</h1>

<img src="{{ asset('img/hero.png') }}" alt="CSP hero image">

<img src="https://laravel.com/img/logotype.min.svg" alt="Laravel logo">

{{-- Задаём некий JS сценарий прямо в HTML. --}}
<script>
console.log('Loaded inline script!');
</script>

{{-- Злой JS-сценарий, который написали не мы мы, а вставил другой сценарий! --}}
<script>
console.log('Внедрённый вредоносный сценарий! ☠️');
</script>
</body>
</html>

First, create a policy class extending Spatie\Csp\Policies\Basic. There is no required directory, so choose a location that suits your application. I prefer app/Support/Csp/Policies, so I will create app/Support/Csp/Policies/CustomPolicy.php:

namespace App\Support\Csp\Policies;

use Spatie\Csp\Policies\Basic;

class CustomPolicy extends Basic
{
public function configure()
{
parent::configure();

// Здесь мы можем добавить свои собственные директивы...
}
}

As the code comment indicates, custom directives belong in the configure method.

Let's add some directives and see what they do:

namespace App\Support\Csp\Policies;

use Spatie\Csp\Policies\Basic;

class CustomPolicy extends Basic
{
public function configure()
{
parent::configure();

$this->addDirective(Directive::SCRIPT, ['https://unpkg.com/vue@3/'])
->addDirective(Directive::STYLE, ['https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/'])
->addDirective(Directive::IMG, 'https://laravel.com');
}
}

This policy generates a Content-Security-Policy header similar to:

base-uri 'self';connect-src 'self';default-src 'self';form-action 'self';img-src 'self';media-src 'self';object-src 'none';script-src 'self' 'nonce-3fvDDho6nNJ3xXPcK3VMsgBWjVTJzijk' https://unpkg.com/vue@3/;style-src 'self' 'nonce-3fvDDho6nNJ3xXPcK3VMsgBWjVTJzijk' https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/

Any JavaScript file whose URL begins with https://unpkg.com/vue@3/ is now permitted, so the Vue.js script can load as expected.

We also allow CSS files whose URLs begin with https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/.

Images from URLs beginning with https://laravel.com are permitted too.

The rules allowing local images, CSS, JavaScript and authorized inline JavaScript are already included in the base policy. We do not need to repeat them, keeping CustomPolicy compact and adding only the extra directives we need, usually for external resources.

At this point, however, our inline JavaScript still would not run. We will fix that next.

The rules above allow the page to load, but we can make them stricter to improve security further.

Imagine a malicious script somehow appeared at a URL beginning with https://unpkg.com/vue@3/, such as https://unpkg.com/vue@3/malicious-script.js. The current rules would allow it. We may therefore want to allow only the exact script URL we need.

Update the policy to specify the exact style, script and image URLs:

namespace App\Support\Csp\Policies;

use Spatie\Csp\Policies\Basic;

class CustomPolicy extends Basic
{
public function configure()
{
parent::configure();

$this->addDirective(Directive::SCRIPT, ['https://unpkg.com/vue@3/dist/vue.global.js'])
->addDirective(Directive::STYLE, ['https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css'])
->addDirective(Directive::IMG, 'https://laravel.com/img/logotype.min.svg');
}
}

This generates a Content-Security-Policy header similar to:

base-uri 'self';connect-src 'self';default-src 'self';form-action 'self';img-src 'self' https://laravel.com/img/logotype.min.svg;media-src 'self';object-src 'none';script-src 'self' 'nonce-20gXfzoeWpjyg1ryUkWAma5gMWNN03xH' https://unpkg.com/vue@3/dist/vue.global.js;style-src 'self' 'nonce-20gXfzoeWpjyg1ryUkWAma5gMWNN03xH' https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css

This approach improves page security because only the required scripts, styles and images may load.

For larger projects, maintaining an explicit entry for every external resource can become time-consuming. Consider that trade-off for each project.

Adding nonces to CSP

We have seen how to allow external resources. Now let's allow the inline scripts we want to run.

Our Blade view contained two inline script blocks:

  • The JavaScript we intend to run.
  • A script injected by an attacker to run malicious code!

The script near the bottom of the Blade file looked like this:

<html>
<!-- ... -->

{{-- Задаём некий JS сценарий прямо в HTML. --}}
<script>
console.log('Loaded inline script!');
</script>

{{-- Злой JS-сценарий, который написали не мы мы, а вставил другой сценарий! --}}
<script>
console.log('Внедрённый вредоносный сценарий! ☠️');
</script>
</body>
</html>

To authorize inline scripts, we can use nonces. A nonce is a random string generated for each request. It is included in the CSP header through the base policy, and an authorized inline script must include the same value in its nonce attribute.

Update the Blade view to add a nonce to the trusted script using the package's csp_nonce() helper:

<html>
<!-- ... -->

{{-- Задаём некий JS сценарий прямо в HTML. --}}
<script nonce="{{ csp_nonce() }}">
console.log('Loaded inline script!');
</script>

{{-- Злой JS-сценарий, который написали не мы мы, а вставил другой сценарий! --}}
<script>
console.log('Внедрённый вредоносный сценарий! ☠️');
</script>
</body>
</html>

The trusted inline script can now run as expected. The injected script has no matching nonce and is blocked.

Using a meta tag

Although unlikely, your Content-Security-Policy header could exceed the maximum permitted length. In that case, you can add a meta tag containing the browser's policy rules.

Add the package's @cspMetaTag Blade directive inside your view's <head> tag:

<html>
<head>
<!-- ... -->

@cspMetaTag(App\Support\Csp\Policies\CustomPolicy::class)
</head>

<!-- ... -->

</html>

With CustomPolicy, this produces the following meta tag:

<meta http-equiv="Content-Security-Policy" content="base-uri 'self';connect-src 'self';default-src 'self';form-action 'self';img-src 'self' https://laravel.com/img/logotype.min.svg;media-src 'self';object-src 'none';script-src 'self' 'nonce-oLbaz3rNhqvzKooMU8KpnqxgO9bFG1XQ' https://unpkg.com/vue@3/dist/vue.global.js;style-src 'self' 'nonce-oLbaz3rNhqvzKooMU8KpnqxgO9bFG1XQ' https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css">

Tips for introducing CSP into an existing Laravel application

Adding CSP to an existing application can be challenging. An overly strict policy, or a missing rule for a resource used on just one page, can easily break the interface. I have made that mistake myself.

If you can introduce CSP when starting a new application, I strongly recommend it. Writing the policy alongside the application makes missing rules less likely. You can even commit policy rules with the resources they permit, making future changes easier to trace.

For an existing application, there are ways to make the process easier for you and your users.

First, enable report-only mode. You can define a policy, but violations are reported to a specified URL rather than causing resources to be blocked. This lets you test the desired CSP in production without disrupting the application. Use the reports to identify missing resources and add the necessary rules.

To enable reporting, set the destination URL for violation reports through CSP_REPORT_URI in .env:

CSP_REPORT_URI=https://example.com/report-sent-here

Then use reportOnly in the policy. The updated policy would look like:

namespace App\Support\Csp\Policies;

use Spatie\Csp\Policies\Basic;

class CustomPolicy extends Basic
{
public function configure()
{
parent::configure();

$this->addDirective(Directive::SCRIPT, ['https://unpkg.com/vue@3/dist/vue.global.js'])
->addDirective(Directive::STYLE, ['https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css'])
->addDirective(Directive::IMG, 'https://laravel.com/img/logotype.min.svg')
->reportOnly();
}
}

The reportOnly method adds Content-Security-Policy-Report-Only instead of Content-Security-Policy. The policy above generates a header like this:

report-uri https://example.com/report-sent-here;base-uri 'self';connect-src 'self';default-src 'self';form-action 'self';img-src 'self' https://laravel.com/img/logotype.min.svg;media-src 'self';object-src 'none';script-src 'self' 'nonce-hI66wwieLS9inQh9GO4iaItVTFoPcNnj' https://unpkg.com/vue@3/dist/vue.global.js;style-src 'self' 'nonce-hI66wwieLS9inQh9GO4iaItVTFoPcNnj' https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css

After a period of observation, perhaps days, weeks or months, if there are no reports and you are confident in the policy, enable enforcement. You can continue receiving violation reports while gaining the security benefits of blocking violations. Remove the reportOnly() call from the policy class.

It can also help to tighten rules gradually, starting with domains or wildcards and moving toward specific URLs as discussed earlier.

A gradual approach is the key to adding CSP to an existing application. You can introduce everything at once, but smaller steps reduce the chance of mistakes and bugs.

Conclusion

I hope this article has explained CSP, the problems it addresses and how it works. You should now know how to implement it in Laravel using spatie/laravel-csp.

You can also read the MDN CSP documentation for more options you can use in your applications.