How to Get Started with a Content Security Policy

Written by: Heiko Webers

A Content Security Policy (CSP) is a great way to reduce or completely remove Cross Site Scripting (XSS) vulnerabilities. With CSP, you can effectively disallow inline scripts and external scripts from untrusted sources. You define the policy via an HTTP header with rules for all types of assets.

On the other hand, that means you’ll have to move all of your own inline scripts to external files. However, that's good practice anyway and usually allows you to reuse a greater amount of scripts than before.

Here’s an example policy HTTP header to allow assets (scripts, CSS, fonts, images, etc.) only from the default source, which is the same origin ('self'). Scripts are also allowed from Google Analytics to make the tracking code work. Everything else is disallowed.

Content-Security-Policy: default-src 'self'; script-src 'self' https://www.google-analytics.com;

The most important CSP directives and values are listed on many sites.

Which Browsers Support CSP?

Your first question, of course, is probably “can I use it, or is it one of those obscure security ideas?”

CSP 1.0 is supported by 80 percent of today’s browsers, including mobile (iOS, Android browser from version 4.4, Chrome for Android). Internet Explorer 10 and 11 (still about 7 percent usage) only support the now deprecated X-Content-Security-Policy header and only the sandbox directive.

Unfortunately the ability to whitelist source domains doesn’t seem to be supported by IE until Edge 12. That means, if you want to use it, you should only use the Content-Security-Policy header, not the deprecated X-Content-Security-Policy header. Also, never send both of them -- that can confuse some browsers. The header is backward compatible; very old browsers just won’t have the extra protection.

Version 1 of the standard already pretty much defined all that’s needed. The current CSP 2.0 (and the intermediate version 1.1) added a few directives, mainly concerning frames, sources for form endpoints and allowed plugin types. It also supports nonces to allow singing inline styles and scripts with a unique hash.

Support for these new directives is still a bit patchy, and only Chrome supports them completely. Firefox is missing the plugin-types, child-src directives. The other browsers basically don’t support them yet.

What will happen if a certain directive isn’t supported by a browser? Here’s a warning from Safari about the unsupported child-src directive:

However, the rest of the directives will still work.

Still, such notifications aren’t very professional, so it’s best to try and recognize the client’s browser to send only the supported directives. GitHub, for example, sends the unsupported child-src, form-action, frame-ancestors and plugin-types directives only to Chrome but not to Safari. And Firefox doesn’t receive the child-src, plugin-types directives.

Luckily, we don’t have to parse user agent names and include CSP rules for each. The SecureHeaders gem does that automatically.

Violation Reports

Great news for a step-by-step introduction strategy is the reporting feature of the standard. Policy violations will be shown in the browser’s console, but can also be POSTed to a URI which will receive a JSON structure like this:

{"csp-report":
  {"document-uri":"...",
  "violated-directive":"script-src 'self' https://ajax.googleapis.com",
  "original-policy":"...",
  "blocked-uri":"https://cdnjs.cloudflare.com"}
}

If you use the Content-Security-Policy-Report-Only header (instead of the Content-Security-Policy one), it will only report violations but won’t block any content. Both headers support the report-uri directive to indicate where the reports should be sent to.

Here are a controller and a migration that you can use for those reports.

Have a Plan to Introduce the Policy

If you’re running a legacy or a larger web app, you probably will have to move some inline JavaScript to external files because we want to disallow inline scripts and styles. That also means this isn’t a plug-and-play security feature, so you’ll need a strategy first:

  1. Move inline scripts to external files. We'll cover this in a bit.

  2. Move assets to a CDN or a subdomain so that you can use only script-src cdn.example.com and disallow the same origin ('self'). Depending on your application, user content that is served by the same origin may contain scripts; for example, in uploaded files, reflected search strings, etc. So it’s another level of protection if you leave out 'self' for script-src. Alternative: It’s not too bad to leave your scripts on the same domain in the beginning.

  3. Start with style-src 'unsafe-inline' (plus a CDN/subdomain/the same origin) to allow inline styles. Iit’s very likely that there will be problems with showing and hiding elements on load and in popular JavaScript libraries (see below).

  4. Introduce the Content-Security-Policy-Report-Only HTTP header first to receive policy violation reports from production while not disallowing anything yet. Once you’ve got the policy sorted, switch to the real header.

  5. The default-src directive defines the default allowed source as a fallback for most of the other *-src directives. You can start with a black or a white list (see below).

Blacklist or Whitelist Strategy?

You can start with a default-src *, which is similar to having no CSP at all. Once you know all of your sources, switch to a whitelist approach by disallowing everything by default (default-src 'none') and allowing only the known sources for the other directives. GitHub, for example, used this approach because they have a lot of user content, and it takes a while to figure out all the sources and move inline code.

If your site is more straightforward, start with default-src 'none'.

The Basic Configuration

Naturally there’s no one-size-fits-all CSP policy, so Rails doesn’t send this HTTP header by default.

Use the SecureHeaders gem because it will send only the CSP directives supported by the user agent. It also makes it easy to add or change directives per controller or action. So, for example, for the payments controller, you can add another script-src, but you don’t have to send it site-wide. Here’s an example configuration:

Gemfile:
gem 'secure_headers'
config/initializers/csp.rb:
SecureHeaders::Configuration.default do |config|
  config.csp = {
    report_only: Rails.env.production?, # Use the Report-Only header
    preserve_schemes: true, # default: false. Schemes are removed from host sources to save bytes and discourage mixed content.
    default_src: %w(*), # all allowed in the beginning, or try ‘none’
    script_src: %w('self' https://ajax.googleapis.com https://www.google-analytics.com),
    connect_src: %w('self'),
    style_src: %w('self' 'unsafe-inline'),
    report_uri: ["/csp_report?report_only=#{Rails.env.production?}"]
  }
end

This will:

  • Send the Content-Security-Policy-Report-Only header in production, and Content-Security-Policy otherwise.

  • Allow everything by default (default-src: *).

  • Allow certain scripts and styles from CDNs and from the same origin ('self'). Styles may also be used 'unsafe-inline' in style HTML attributes.

  • AJAX requests may only go to the same origin POST JSON violation reports to CspReportsController#create.

Add a Policy Violation Report Endpoint

Create a table and a controller for the CSP violation reports as described here. You might receive a lot of false positives, so over time you might need to add a few exceptions to what you log.

Go through the list regularly and tweak the header, maybe only for a single controller.

Unsafe Styles

The style-src includes 'unsafe-inline' because popular libraries like jQuery and Bootstrap inject styles to hide and show elements. Try to remove it if you don’t use such libraries and use CSS classes to show and hide elements on load.

In general, styles may be unsafe because CSS may contain JavaScript: URIs which still work in some browsers. Modern browsers may cancel such requests or such URIs are anyway disallowed by CSP. So 'unsafe-inline' should be safe enough until those libraries use a different approach.

Whitelist CDN Scripts

If you use jQuery, Bootstrap, or anything like that, chances are that a CDN delivers them. Google’s CDN for jQuery is located at https://ajax.googleapis.com, so we’re adding that domain to the script-src directive.

You can save a few bytes in the header and leave out the https://. But that would allow also the HTTP version of it. That's probably okay, but it might be intercepted.

You might also have something like the following fallback code to load jQuery from your server:

<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.2.2/jquery.min.js"></script>
<script>
  (function() {
    if (typeof jQuery === "undefined" || jQuery === null) {
      document.write('<%= javascript_include_tag 'jquery'%>');
    }
  }).call(this);
</script>

This will violate the script-src directive because inline scripts aren’t allowed anymore. So we’ve got to convert it to an external file:

app/assets/javascripts/jquery_loader.js.erb
(function() {
if (typeof jQuery === "undefined" || jQuery === null) {
    document.write('<%= javascript_include_tag 'jquery'%>');
  }
}).call(this);
app/assets/javascripts/application.js
//= require jquery_loader

You can use the same approach to include a Google Analytics tracking code. That’s why we added https://www.google-analytics.com to the script-src because it loads the script from that domain. Other popular libraries and services need different sources.

Convert Your Inline Scripts

Now you’ve seen how to move inline code to external files. However, we don’t want too many or too few of them, so one file per controller seems like a good approach. Add javascript_include_tag controller_name to your application layout and create a file for each controller in app/assets.

That will probably work for less complicated applications. In a larger application, you might already have a way to organize scripts. If not, here’s another suggestion that also works with Turbolinks:

  • Add a scope to every page with <body class="<%= controller_name %> <%= action_name %>">.

  • One application script will run “always on” code like Bootstrap tooltips. Add more script classes per controller or per “functionality” (a chart, cart, modals etc.).

  • At the start of the page-specific code, add something like return unless $(".posts.index").length > 0 to run this only on the PostsController#index page. If you have a lot of page-specific code, this might not be the right approach. But this could also be a good chance to reduce duplication.

Note that this approach loads all scripts at once which might increase initial load time. However, after that, the page load speed will be much quicker. Feel free to use a different approach if you dislike large script files.

Convert JavaScript in HTML Attributes

If you’ve got something like this:

<button class='my-javascript-button' onclick="alert('hello');">

You probably know that you can convert it to this in an external file:

$(document).ready(function () { // or list to page:change for Turbolinks
  $('.my-javascript-button').on('click', function() {
    alert('hello');
  });
});

Convert Scripts that Need Dynamic Input

Okay, now all scripts are in external files, but you used inline scripts before because they required data that you rendered right into the script in a view. Unless you serve the entire script with Rails (and not as an asset), you’ll need to separate scripts and data.

One way to do that is using static scripts which refer to data-* attributes of relevant elements. This is an example from the JavaScript Rails guide:

Before

<a href="#" onclick="paintIt(this, '#990000')">Paint it red</a>

After

<a href="#" data-background-color="#990000">Paint it red</a>

Unobtrusive CoffeeScript

@paintIt = (element, backgroundColor, textColor) ->
  element.style.backgroundColor = backgroundColor
  if textColor?
    element.style.color = textColor
$ ->
  $("a[data-background-color]").click (e) ->
    e.preventDefault()
    backgroundColor = $(this).data("background-color")
    textColor = $(this).data("text-color")
    paintIt(this, backgroundColor, textColor)

AJAX

The policy above includes a connect-src ‘self’ directive which allows AJAX requests to the same origin and should be fine for most applications.

But one of the biggest tasks for CSP can be converting actions that return JavaScript, like Rails *.js.erb views that are often used for AJAX responses. AJAX responses that include scripts are evaluated using eval(). That isn’t allowed with CSP unless you add script-src ‘unsafe-eval’.

However, this could be a good chance to separate markup and data. Further down in the suggestion from earlier is a section about user-triggered JavaScript:

  • Add a data-behavior=”update-credit-card” attribute to elements that trigger AJAX.

  • React to the click in an external file: $(document).on "click", "[data-behavior~=update-credit-card]".

  • Initiate an AJAX call if you need data from the server.

  • Change the AJAX action to not return a script, but JSON, markup, or similar.

That’s a little more work than just link_to(..., remote: true) and a *.js.erb file. But after that, you’ll have JavaScript completely separated just like your CSS is already.

So why are we doing all this? Once you have a Content-Security-Policy and there was a Cross-Site Scripting vulnerability in your app, the browser would refuse to run any injected scripts:

Start Developing Your Content Security Policy

CSP is one of the most effective web application security novelties that's ready to go mainstream. Browser support is great, and it’s one of the most important features that we’re implementing in my monthly Rails security modernization service.

But you’ll need an introduction strategy because it forces you to be more strict with the separation of markup, styling, and behavior. So find your strategy to convert existing scripts, be consistent, and you’ll end up with a secure and modern web application. I’ve prepared a bit of code and a quick reference for your convenience.

Stay up to date

We'll never share your email address and you can opt out at any time, we promise.