Ruby on Rails Content-Security-Policy (CSP)

Ruby on Rails Content-Security-Policy (CSP)

Updated 2018: The full Content Security Policy guide for Rails 5.2 (and earlier) apps.

How to add a Content Security Policy (CSP) to Rails?

The CSP HTTP header is a set of rules for the browser. You can use it to whitelist sources for scripts, styles, embedded content, and more. I.e. all other sources are disallowed.

CSP is a great way to reduce or completely remove Cross-Site-Scripting (XSS) vulnerabilities. That would wipe out the number 1 web app security problem. Why? Because an effective CSP disallows inline scripts. It only allows scripts in separate files from trusted sources.

Here’s how the header looks like if you want to allow scripts only in files from the same origin and from G* Analytics:

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

What does CSP protect us from?

  • JavaScript injection in a HTML context. CSP can’t help with HTML injection, but can influence whether scripts are allowed then.
  • JavaScript injection injection in a JS context is pretty much game-over. CSP can’t help here usually, but it’s also rather rare.
  • Restrict from where frames can be included, or where someone can iframe this site.
  • Allow only appropriate images
  • Disallow user stylesheets (yes, also they can be harmful)
  • Auto upgrade URLs to HTTPS with the upgrade-insecure-requests directive
  • A second line of defense: If someone was able to inject something, we’d have more protection.

Which header should I send?

It all started with the X-Content-Security-Policy and X-Webkit-CSP HTTP headers, but they’re deprecated now. Going forwards, you should only send either Content-Security-Policy or Content-Security-Policy-Report-Only. As of 2018 the support rate for version 1 of the standard is >90%. CSP version 2 added a few features, and the major browsers support it, but currently the support rate is around 75%.

Rails and the Content-Security-Policy configuration

Rails 5.2 added CSP support, but in earlier versions you can add the header yourself. Or use the SecureHeaders gem. It recognizes the browser and will send only the CSP directives supported by it. It doesn’t do any harm sending unsupported directives. But CSP script warnings in the developer console are unnecessary.

The general approach

  • Choose a restrictive enough policy that requires the least work of moving or changing code.
  • Use a special Firefox (“Laboratory”) or Chrome add-on (“CSP Mitigator”) to live-test the policy on your site and adjust it.
  • Start the “report-only” CSP mode with no negative effects for your users
  • Collect violation reports and check them on a regular basis
  • Tweak the policy
No time right now? I'll send you a summary and Rails Content-Security-Policy updates.

Legacy Rails app vs. modern default Rails approach

I’ll call it a legacy Rails app if:

  • you have a lot of inline scripts and styles
  • Ajax responds with JavaScript

More recent Rails apps likely use a different JavaScript architecture:

  • Ajax responses return one content type: JSON, text or HTML (e.g. for Turbolinks)
  • Preloaded scripts interpret those responses
  • JavaScript live in external files and aren’t tied up with the DOM

The “traditional” CSP approach for a legacy Rails app

There’s no default Content-Security-Policy in Rails 5.2. And it’s sensible because a useful one is tailor-made. The default SecureHeaders CSP policy allows pretty much everything “https” and from the same origin ('self'). In browser security terms, the same origin means the same protocol scheme, host, and port. And there are three main restrictions in this default policy:

  • No embedded content (object_src) allowed
  • No inline scripts allowed. If you’ve inline scripts, you can get rid of the warnings by adding 'unsafe-inline' to script_src. But that should only be temporary. Try to move all inline JS to external files. Or use the nonce-based approach below.
  • Ajax responses with inline JavaScript would require unsafe-eval in script_src. But that makes the policy rather useless. If you want to use CSP, try to convert these responses and add unsafe-eval only temporary.

config/initializers/csp.rb:

SecureHeaders::Configuration.default do |config|
  config.csp = {
    default_src: %w(https: 'self'),
    font_src: %w('self' data: https:),
    img_src: %w('self' https: data:),
    object_src: %w('none'),
    script_src: %w(https:),
    style_src: %w('self' https: 'unsafe-inline')
  }
end

Once this CSP works you can start locking down the script whitelist. Your goal in this approach should be 1-3 hosts for your own scripts. And a limited whitelist for 3rd-party scripts, if needed. But try to limit the dependencies as much as possible. Every extra whitelisted host adds to the risk of bypasses via JSONP (see below).

Trying to get some inspiration for this approach? Here are the key CSP directives from Github in Rails 5.2 syntax:

Rails.application.config.content_security_policy do |policy|
  ...
  policy.default_src :none
  policy.script_src  "assets-cdn.github.com"
  policy.style_src   :unsafe_inline, "assets-cdn.github.com"
  policy.connect_src :self, "uploads.github.com", "status.github.com", "collector.githubapp.com", ...
end

This is a true whitelist with nothing allowed by default (default-src: 'none'). But note that Github had significant effort to move all scripts to a single host, including analytics.

The nonce-based approach for a legacy Rails app

Instead of a host whitelist, you can also ‘sign’ a script using an unguessable random value (a ‘nonce’).

Define a nonce in the CSP header like so: script-src 'nonce-{random}'. Now you can use it to whitelist one script (or more): <script nonce="{random}">{JavaScript}</script>. The SecureHeaders gem has a handy method nonced_javascript_tag to ease that. Rails 5.2 added a nonce: true option to the javascript_tag method.

You might already know that. But note that there’s a second way to use nonces with external scripts: <script nonce="{random}" src="{URL}"></script>.

That means you don’t have to whitelist this script’s host. That’s a good way to include scripts from hosts with known bypasses. Above script doesn’t work in MS Edge though due to a bug. SecureHeaders also has a method for that: nonced_javascript_include_tag.

The nonce-based approach is best for applications with a classic request-response model. It also helps you to use a CSP now, while slowly moving inline JS to external files and develop a whitelist.

Ajax is also possible in this approach. But it can’t help with JS in Ajax responses, e.g. in *.js.erb views. Try to use a different JS architecture for these Ajax responses.

Also, you’ll need to test your 3rd-party JS libraries whether they load more dynamic dependencies from additional hosts. You can add those extra hosts to the whitelist. Or if the 3rd-party JavaScript snippet is rather small, you can also rewrite it to use nonces at all levels.

But this approach doesn’t play well with caches, single-page apps or Turbolinks. Because the document in the browser has one CSP nonce and the next request uses already another. For JSON Ajax requests that should be fine. But e.g. Turbolinks replace the entire page, but not the CSP that the browser expects.

To solve that you can cache a nonce and use it again as long as the session lives. That makes Turbolinks or similar work again because all following nonces are the same. You can use the handy csp_meta_tag method from Rails 5.2 for that. Or do it yourself when you’re using SecureHeaders.

Nonces were added in CSP version 2. Note that nonces are backwards-compatible with browsers that support only CSP version 1. If you include 'unsafe-inline' together with a nonce, modern browsers will ignore 'unsafe-inline'. But older browsers will allow the nonced inline scripts because of the 'unsafe-inline'.

The hash-based approach for a legacy Rails app

Calculate the digest of a static inline script <script>{JavaScript}</script> and add it to the CSP config like so: script-src 'sha256-{digest(JavaScript)}'.

This makes it easy to include a small number of inline scripts. It should also work fine with caches because a digest stays valid. But it doesn’t play well with single-page apps or Turbolinks. That’s because Turbolinks don’t update the CSP on page change. But of course you could list all hashes of all inline scripts in the CSP header.

The SecureHeaders gem also has two helper methods: hashed_javascript_tag and hashed_style_tag.

The ‘strict-dynamic’ approach for a legacy Rails app

Firefox, Chrome and Opera also support CSP version 3 which includes the strict-dynamic directive value. Microsoft Edge has it under consideration.

The “traditional” whitelist approach may be vulnerable to bypasses via JSONP. That’s why 'strict-dynamic' came into place. JSONP is a legacy technology for cross-origin request. JSONP endpoints return the callback JavaScript from request params.

This approach makes it easier to deploy in the first place. 'strict-dynamic' will allow ‘nonced’ scripts to load more dependencies. The idea is to trust already whitelisted scripts to load only trustworthy scripts.

On the other hand it means you loose full control over the whitelist. But for some apps that’s not possible anyway. And strict-dynamic clearly makes it easy to adopt a (rather) strict policy.

You’ll only need to refactor anything that uses javascript: URIs or onclick handlers. Note that 'strict-dynamic' is backwards-compatible. When supporting browsers see a 'strict-dynamic' policy, they will disregard the whitelist. For example:

Content-Security-Policy script-src 'unsafe-inline' https: 'nonce-123' 'strict-dynamic';

In browsers that support CSP1, it will act like 'unsafe-inline' https:. When it supports CSP2, it acts like https: 'nonce-123'. And like 'nonce-123' 'strict-dynamic' in browsers that support CSP3.

CSP for a perfectly organized app

Here’s an example of what Content-Security-Policy config to aim for long-term. The main architecture decisions here are that static scripts and (config or user) data are completely separated. Also, all assets live on a separate CDN host. That might not be the best architecture for every application. But it has a few advantages:

  • Better caching possibilities
  • Faster delivery if it’s a real CDN
  • Separation of data and logic is in line with general Rails principles

But it can be quite some work to untangle scripts and data.

This example is in the SecureHeaders syntax style. The Rails 5.2 DSL is only slightly different (using lists instead of Arrays for the values).

config/initializers/csp.rb

SecureHeaders::Configuration.default do |config|
  config.csp = {
    default_src: %w('none'), # nothing allowed by default
    script_src: %w(cdn.example.com),
    connect_src: %w('self'),
    img_src: %w(cdn.example.com data:),
    font_src: %w(cdn.example.com data:),
    base_uri: %w('self'),
    style_src: %w('unsafe-inline' cdn.example.com),
    form_action: %w('self'),
    report_uri: %w(/mgmt/csp_reports)
  }
end

Further observations:

  • All scripts are static and unobtrusive. I.e. no inline scripts and all configuration lives in the DOM. For example in a <meta> tag.
  • Moving all styles to external files proves to be rather difficult, so 'unsafe-inline' is still pretty common.
  • A lot of CSS contains inline images using the data: pseudo-protocol
  • The base-uri directive tells browsers which <base href="..."> URLs are allowed. The browser uses that URL for relative URLs if present.

This is what you’ll need to add to your CSP config file if you use the following libraries and services:

  • G* Analytics: Add https://www.google-analytics.com to script-src and img-src for the default pixel image tracking.
  • jQuery on Google CDN aka https://ajax.googleapis.com: This hosts JSONP endpoints so you should avoid it in the whitelist, if possible. Either nonce the external script (see above), or use your own CDN.
  • Bootstrap: Add https://maxcdn.bootstrapcdn.com to script-src and style-src.
  • Facebook Like button: Add https://www.facebook.com to frame_src (and the deprecated child-src if needed) for the IFrame approach. Then move the facebook-like-frame style to your own CSS unless you have 'unsafe-inline' in your style-src.

About source URLs

If you leave out the protocol in CSP whitelists, only downgrades trigger a CSP violation. Downgrade here means when loading from http:// sources on a HTTPS-only website.

  • If your site is HTTPS-only, you can leave out the https:// in all source URLs. You likely already took care of all non-secure hosts to avoid mixed-content warnings. That means all sources are HTTPS and there won’t be a downgrade ever. You can save a few bytes and not mention the protocol.
  • If your site lives on http:// only, you can also leave out the protocol in all sources. No source will ever be a downgrade.
  • Only if you offer both protocols, downgrade may happen. Make sure you mention the correct protocol always. Long-term you should probably move to HTTPS-only, or keep both versions very separate.

Note that there’s no need serve a CSP for the redirect response from HTTP to HTTPS.

There’s a preserve_schemes option in the SecureHeaders gem which removes all protocols from your sources when set to false.

Also make sure that the source hosts don’t serve JSONP responses or Angular libraries. JSONP endpoints reflect user input and thus often allow arbitrary JS. That would circumvent this whole Cross-Site-Scripting protection.

Cross-check your host list in the Google CSP evaluator for known bypasses.

The same CSP everywhere?

It might not be very obvious, but you can serve a different CSP for every request. In practice though, you’re restricted by Turbolinks or Ajax requests. But you can definitely do different CSPs per area. Or one for the marketing site, one for the app.

This is how to override the default policy in controllers from Rails 5.2:

class PostsController < ApplicationController
  content_security_policy do |p|
    p.img_src "data:"
  end
end

The SecureHeaders gem uses so-called named overrides. This keeps all configuration in the initializer and then you switch the policy like so:

Config:

SecureHeaders::Configuration.override(:payment) do |config|
  config.csp[:script_src] << "otherdomain.com"
end

Controller action:

use_secure_headers_override(:payment))

Directives

Each policy rule consists of a directive and one or more values separated by spaces. Each rule is separated by semicolons. Example:

directive1: value1 [value2, ...]; [directive2: ...]

These are the most important directives. See below for the values.

CSP Directive Definition
default-src This is the fallback source for basically all source directives (*-src) if the specific source isn't defined.
script-src Allowed sources for JavaScript.
style-src Allowed sources of CSS styles.
img-src Allowed sources of images.
font-src Allowed sources of fonts.
object-src Allowed sources of embedded content via <object>, <embed> or <applet>.
media-src Allowed sources of HTML5 <audio> and <video> elements.
frame-src First deprecated in favor of child-src, now it's back. Defines the allowed sources for <frame> and <iframe>.
child-src Now deprecated. Use frame-src and worker-src instead, this defined the valid sources for both of them.
worker-src Valid sources for web workers.
frame-ancestors Which parent sites may include this site in a <frame> or <iframe>. This can be a replacement for the X-Frame-Options security header.
connect-src Allowed endpoints for Ajax, Websockets and HTML5 EventSource.
sandbox A list of flags to implement a sandbox to (dis)allow forms, popups, for example.
report-uri A report endpoint URI where the browser will POST a JSON formatted violation report.
plugin-types The allowed plugin MIME types that the user agent may use. For example: plugin-types application/pdf application/x-shockwave-flash.
form-action Endpoints that can be used for <form> submissions.

In case you wondered, there’s no inheritance. The other directives don’t inherit from the default-src. A specific source directive will overwrite the default-src. There are a few other directives described here.

Directive values

  • * as in img-src * allows any URL, except the data:, blob: or file: pseudo-protocols. You need to add them separately.
  • 'none' as in object-src 'none' is the opposite, allows nothing
  • 'self' as in script-src 'self' allows scripts from the same origin (same protocol scheme, host, and port)
  • data: as in img-src data: allows images via the data scheme (often Base64 encoded images)
  • A URL as in img-src cdn.example.com allows images from that exact domain. It doesn’t contain a protocol, that means no protocol downgrade is allowed. So no https://yoursite.com to http://cdn.example.com.
  • Wildcard URLs as in script-src *.example.com allow scripts from all subdomains on that domain
  • A protocol as in img-src https: allows sources only over HTTPS on any domain
  • 'unsafe-inline' as in script-src 'unsafe-inline' allows inline scripts (or for styles: style-src: 'unsafe-inline')
  • 'unsafe-eval' as in script-src 'unsafe-eval' allows dynamic evaluation of JavaScript via eval(), but it defeats the purpose of CSP. This is also needed for the former *.js.erb Ruby on Rails Ajax responses.

CSP violation reports

The browser will send CSP violation reports to the URI in the report-uri (report_uri in the Rails config DSL) directive. You’ll need this for the Content-Security-Policy-Report-Only header to adjust the policy before going live. But you’ll also need it for the production policy to monitor it.

Note that this endpoint might see quite some traffic. So you might want to only save incoming reports and process them later. But you can use this endpoint controller code to get started:

class CspReportsController < ApplicationController
  skip_before_action :verify_authenticity_token
  skip_before_action :require_user_signed_in

  def create
    report_base = JSON.parse(request.body.read)
    if report_base.has_key? 'csp-report'
      report = report_base['csp-report']
      CspReport.create(
        blocked_uri: report['blocked-uri'].try(:downcase),
        disposition: report['disposition'].try(:downcase),
        document_uri: report['document-uri'],
        effective_directive: report['effective-directive'].try(:downcase),
        violated_directive: report['violated-directive'].try(:downcase),
        referrer: report['referrer'].try(:downcase),
        status_code: (report['status-code'].presence || 0).to_i,
        raw_report: report,
        raw_browser: request.headers['User-Agent']
      )
    end
    head :ok
  end
end

This is the SecureHeaders configuration for the endpoint:

SecureHeaders::Configuration.default do |config|
  config.csp = {
    ...
    report_uri: %w(/csp_reports)
  }
end

And here’s the table migration:

class CreateCspReports < ActiveRecord::Migration[5.1]
  def change
    create_table :csp_reports do |t|
      t.string :blocked_uri
      t.string :disposition
      t.string :document_uri
      t.string :effective_directive
      t.string :violated_directive
      t.string :referrer
      t.integer :status_code, null: false, default: 0
      t.jsonb :raw_report, default: {}, null: false
      t.string :raw_browser
      
      t.timestamps
    end
  end
end

And here’s how the report’s JSON looked like when I injected an inline script (which was disallowed):

{
   "csp-report": {
      "blocked-uri": "self",
      "document-uri": "http://localhost:3000/",
      "original-policy": "...",
      "referrer": "",
      "source-file": "http://localhost:3000/",
      "violated-directive": "script-src"
   }
}

Take good caution with the endpoint and the reports. An attacker might run a DoS attack against it if the endpoint is slow. But if you deploy a bug, you’ll also get a lot of legitimate reports. But still, it can be a good idea to rate-limit the endpoint.