How to Improve Your Laravel Application's Security Using a CSP
Published on by Ashley Allen
Content Security Policies (CSP) are a great way to improve the security of your Laravel application. They allow you to whitelist the sources of scripts, styles, and other assets that your webpages can load. This prevents an attacker from injecting malicious code into your view (and consequently, your users' browsers) and can give you added confidence that the third-party resources you're using are what you intended to use.
In this article, we're going to take a look at what a CSP is and what they achieve. We'll then take a look at how to use the spatie/laravel-csp package to add a CSP to your Laravel application. We'll also briefly cover some tips to make adding a CSP to an existing application easier.
What is a Content Security Policy?
In its simplest terms, a CSP is just a set of rules that are typically returned from your server to the client's browser via a Content-Security-Policy
header in the response. It allows us, as developers, to define what assets the browser is allowed to load.
As a result of this, it can give us confidence that our users are only loading images, fonts, styles, and scripts into their browser that we've deemed safe for them to load and permitted the use of. If the browser tries to load an asset that isn't permitted, it will be blocked.
Using a well-configured content security policy can reduce the likelihood of user data theft and other malicious actions carried out using attacks such as Cross-Site Scripting (XSS).
CSPs can become very complex (especially in larger applications) but they are a vital part of any application's security.
How to Implement a CSP in Laravel
As we've already mentioned, a CSP is just a set of rules that are returned from your server to the client's browser via a header in the response, or sometimes defined as a <meta>
tag in the HTML. This means that there's several ways that you can apply a CSP to your application. For example, you could define the headers in your server's (e.g. - Nginx) configuration. However, this can be cumbersome and difficult to manage, so I find that it's easier to manage the policy at the application level instead.
Typically, the easiest way of adding a policy to your Laravel application is to use the spatie/laravel-csp package. So let's take a look at how we can use it, and the different options that it provides us with.
Installation
To get started with using the spatie/laravel-csp
package, we'll first need to install it via Composer using the following command:
composer require spatie/laravel-csp
Following this, we can then publish the package's config file using the following command:
php artisan vendor:publish --tag=csp-config
Running the above command should have created a new config/csp.php
file for you.
Applying the Policy to Responses
Now that the package is installed, we need to make sure the Content-Security-Policy
header is added to your HTTP responses. There are a few different ways you might want to do this, depending on your application.
If you'd like to apply the CSP to all your web routes, you could add the Spatie\Csp\AddCspHeaders
middleware class to the web
part of the $middlewareGroups
array in your app/Http/Kernel.php
file:
// ... protected $middlewareGroups = [ 'web' => [ // ... \Spatie\Csp\AddCspHeaders::class, ], // ...
As a result of doing this, any route that runs through your web
middleware group, will have the CSP header automatically added for you.
If you'd prefer to add the CSP to individual routes or any route groups, you can use the middleware in your web.php
file instead. For example, if we only wanted to apply the middleware to a specific route, we might do something like:
use Spatie\Csp\AddCspHeaders; Route::get('example-route', 'ExampleController')->middleware(AddCspHeaders::class);
Or, if we wanted to apply the middleware to a route group, we could do the following:
use Spatie\Csp\AddCspHeaders; Route::middleware(AddCspHeaders::class)->group(function () { // Routes go here...});
By default, if you don't explicitly define the policy that should be used with the middleware, the policy defined in the default
key of your published config/csp.php
file will be used. So you may want to update that field if you want to use your own default policy.
It's possible that you may have several content security policies for your application or website. For instance, you might have a CSP for use on the public pages of your site, and another CSP for use on the gated parts of your site. This could be due to you using different sets of assets (such as scripts, styles, and fonts) in each of these places.
So if we wanted to explicitly define the policy that should be used for a specific route, we could do the following:
use App\Support\Csp\Policies\CustomPolicy;use Spatie\Csp\AddCspHeaders; Route::get('example-route', 'ExampleController')->middleware(AddCspHeaders::class.':'.CustomPolicy::class);
Similarly, we could also explicitly define the policy in a route group:
use App\Support\Csp\Policies\CustomPolicy;use Spatie\Csp\AddCspHeaders; Route::middleware(AddCspHeaders::class.':'.CustomPolicy::class)->group(function () { // Routes go here...});
Using the Default Content Security Policy
The package ships with a default Spatie\Csp\Policies\Basic
policy that defines a few rules already for us. The policy only allows us to load images, fonts, styles, and scripts from the same domain as our application. If you're only using assets that are loaded from your own domain, this policy may be enough for you.
The Basic
policy would create a Content-Security-Policy
header that looks something 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 Your Own Content Security Policy
Depending on your application, you may want to create your own policy to allow loading other assets that are permitted by the Basic
policy.
As we've already mentioned, there are a lot of rules that can be defined in a CSP, and they can become relatively complex quickly. So to help you get a brief understanding, we'll take a look at a few of the common rules that you'll likely be using in your own application.
For the purpose of this guide, we're going to make the assumption that we have a project that uses the following assets on the page:
- A JavaScript file available on the site's domain at:
/js/app.js
. - A JavaScript file available externally at:
https://unpkg.com/vue@3/dist/vue.global.js
. - Inline JavaScript - But not just any inline JavaScript, we only want to allow inline JavaScript that we've explicitly permitted to run.
- A CSS file available on the site's domain at:
/css/app.css
. - A CSS file available externally at:
https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css
- An image available on the site's domain at:
/img/hero.png
. - An image available externally at:
https://laravel.com/img/logotype.min.svg
.
We'll create a content security policy that only allows the loading of the above items on our page. If the browser attempts to load any other asset, the request will be blocked and it won't be loaded.
The basic Blade view for the page may look something like this:
<html> <head> <title>CSP Test</title> {{-- Load Vue.js from the CDN --}} <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> {{-- Load some JS scripts from our domain --}} <script src="{{ asset('js/app.js') }}"></script> {{-- Load 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" > {{-- Load a CSS file from our own domain --}} <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"> {{-- Define some JS directly in our HTML. --}} <script> console.log('Loaded inline script!'); </script> {{-- Evil JS script which we didn't write ourselves and was injected by another script! --}} <script> console.log('Injected malicious script! ☠️'); </script> </body></html>
To get started, we'll first want to create our own policy class that extends the package's Spatie\Csp\Policies\Basic
class. There isn't a particular directory that you need to place this in, so you can choose somewhere that works best for your application. I like to place mine in an app/Support/Csp/Policies
directory, but that's just my preference. So I'll create a new app/Support/Csp/Policies/CustomPolicy.php
file:
namespace App\Support\Csp\Policies; use Spatie\Csp\Policies\Basic; class CustomPolicy extends Basic{ public function configure() { parent::configure(); // We can add our own policy directives here... }}
As you can see from the comment in the code above, we can place our own custom directives in the configure
method.
So let's add some directives and take a look at 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'); }}
The above policy would create a Content-Security-Policy
header that looks something 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-3fvDDho6nNJ3xXPcK3VMsgBWjVTJzijk' https://unpkg.com/vue@3/;style-src 'self' 'nonce-3fvDDho6nNJ3xXPcK3VMsgBWjVTJzijk' https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/
In our example above, we've defined that any JS file that is loaded from a URL that begins with https://unpkg.com/vue@3/
can be loaded. This means that our Vue.js script would be able to load as expected.
We've also permitted any CSS file that is loaded from a URL that begins with https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/
to be loaded.
Additionally, we've also permitted any image that is fetched from a URL that begins with https://laravel.com
to be loaded.
You might also be wondering where the directives are for allowing inline JavaScript to be run, and images, CSS, and JS files to be loaded from our domain. These are all included in the Basic
policy, so we don't need to add them ourselves. So we can keep our CustomPolicy
nice and lean, and only add the directives that we need (typically for external assets).
However, at the moment, if we were to try running our inline JavaScript, it wouldn't work. We'll cover how to fix this further down.
Although the rules above work and will allow our page to load as expected, you might want to make the rules stricter to further increase the security of the page.
Let's imagine that, for any unknown reason, a malicious script managed to make its way to a URL that starts with https://unpkg.com/vue@3/
, such as https://unpkg.com/vue@3/malicious-script.js
. Due to the current configuration of our rules, this script would be allowed to be run on our page. So instead, we might want to explicitly define the exact URL of the script that we want to allow to be loaded.
We'll update our policy to include the exact URLs of the scripts, styles, and images that we want to load:
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'); }}
The above policy would create a Content-Security-Policy
header that looks something like this:
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
By using this approach above, we can vastly improve the security of our page, as we're now only allowing the exact scripts, styles, and images that we want to be loaded.
However, as you might imagine, doing this for larger projects can become tedious and time-consuming because you'll need to define every single asset you're loading from an external source. So this is something that you'll need to consider on a project-by-project basis.
Adding Nonces to Your CSP
Now that we've looked at how we can allow external assets to be loaded, we also need to take a look at how we can allow inline scripts to be run.
You might recall that we had two inline script blocks in our Blade view above:
- One that loaded the JS that we intended to run
- One that was injected by a malicious script and ran some evil code!
The script was added to the bottom of the Blade view looked like so:
<html> <!-- ... --> {{-- Define some JS directly in our HTML. --}} <script> console.log('Loaded inline script!'); </script> {{-- Evil JS script which we didn't write ourselves and was injected by another script! --}} <script> console.log('Injected malicious script! ☠️'); </script> </body></html>
To allow inline scripts to be run, we can make use of "nonces". A nonce is a random string that is generated for each request. This string is then added to the CSP header (added via the Basic
policy that we are extending), and any inline script that is loaded must include this nonce in its nonce
attribute.
Let's update our Blade view to include the nonce for our safe inline script by using the csp_nonce()
helper provided by the package:
<html> <!-- ... --> {{-- Define some JS directly in our HTML. --}} <script nonce="{{ csp_nonce() }}"> console.log('Loaded inline script!'); </script> {{-- Evil JS script which we didn't write ourselves and was injected by another script! --}} <script> console.log('Injected malicious script! ☠️'); </script> </body></html>
As a result of doing this, our safe inline script will now be run as expected. Whereas the injected script that doesn't have the nonce
attribute will be blocked from running.
Using a Meta Tag
It's unlikely, but it's possible you may find that the contents of your Content-Security-Policy
header exceeds the maximum allowed length. If this is the case, we can add a meta
tag to our page that outputs the rules for our browser instead.
To do this, you can add the package's @cspMetaTag
Blade directive to your view's <head>
tag like so:
<html> <head> <!-- ... --> @cspMetaTag(App\Support\Csp\Policies\CustomPolicy::class) </head> <!-- ... --> </html>
Using the example of our CustomPolicy
above, this would output 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 Implementing a CSP in an Existing Laravel Application
Adding a CSP to an existing application can be a pretty difficult task sometimes. It's very easy to break your user interface by implementing a CSP that is too strict, or forgetting to add a rule for a particular asset that might only be used on one page. I'll hold my hand up and admit that I've done this myself before.
So if you get the chance to implement a CSP when first starting a new application, I'd highly recommend doing it. It's much easier to write the policy alongside building the application. There's a smaller chance of you forgetting to add specific rules and you can even add the policy rules in the same git commit as you've added the asset so you can easily track it in the future.
However, if you're adding the CSP to an existing application, there are a few things you can do to make the process easier for yourself and your users.
First off, you can enable "report only" mode for your policy. This allows you to define your policy, but whenever any of the rules are violated (such as loading an asset that's not been permitted to be loaded), a report will be sent to a given URL rather than blocking the asset from loading. By doing this, it allows you to create the CSP that you'd like to use and test it in your production environment without breaking your application for users. You can then use the reports to identify any assets that you've missed and add them to your policy.
To enable reporting for your policy, you'll first need to set the URL that the request should be made to when a violation is detected. You can add this by setting the CSP_REPORT_URI
field in your .env
file like so:
CSP_REPORT_URI=https://example.com/report-sent-here
You can then use the reportOnly
method in your policy. If we were to update our policy to only report violations, it would look like so:
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(); }}
As a result of using the reportOnly
method, a Content-Security-Policy-Report-Only
header will be added to the response instead of the Content-Security-Policy
header. The policy above would generate a header that looks like so:
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 certain period of time (maybe a few days, week, or months), if you've not received any reports, and you have confidence that the policy is suitable, you could enable it. This means that you'll still be able to get the reports when there's a violation, but you'll also be able to get the full security benefits of implementing the policy, as any violations will be blocked. To do this, you can remove the reportOnly
method call from your policy class.
Additionally, you may also find it useful to incrementally increase the strictness of your rules like we covered earlier in this article. So may want to only use domains or wildcards in your initial CSP and then gradually change the rules to use more specific URLs.
All in all, I think the key to adopting using CSPs in your existing application is to approach it gradually. It's definitely possible to add it all in one go, but you can reduce the chance of errors and bugs by taking a more incremental approach.
Conclusion
Hopefully, this article should have given you an overview of CSPs, the issues they solve, and how they work. You should also now know how to implement a CSP in your own Laravel application using the spatie/laravel-csp package.
You may also want to check out the MDN documentation on CSPs, which is explains more of the options available for you to use in your applications.
I am a freelance Laravel web developer who loves contributing to open-source projects, building exciting systems, and helping others learn about web development.