Ruby on Rails Content-Security-Policy (CSP)

Ruby on Rails Content-Security-Policy (CSP)

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;

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. The support rate for version 1 of the standard is >80%. CSP version 2 added a few features, but it's not supported by all major browsers, yet. Currently it's around 70% and rising.

Rails and the Content-Security-Policy configuration

Rails doesn't send this HTTP header by default. You can add the headers yourself, but the SecureHeaders gem makes it easy to send only the CSP directives supported by the user agent. It doesn't do any harm sending more directives, but so the user won’t see any unnecessary script warnings in the console.

CSP for a legacy or larger Rails app

If you have a lot of inline scripts, styles, Ajax responses including JS, start by allowing everything, move inline scripts to external files and serve scripts and styles from the same origin.

config/initializers/csp.rb:

SecureHeaders::Configuration.default do |config|  
  config.csp = {
    report_only: Rails.env.production?, # default: false
    preserve_schemes: true, # default: false.
    default_src: %w(*), # all allowed in the beginning
    script_src: %w('self'),
    connect_src: %w('self'),
    style_src: %w('self' 'unsafe-inline'),
    report_uri: ["/csp_report?report_only=#{Rails.env.production?}"]
  }
end  

CSP for a perfectly organized app

If you've all your scripts, styles, images in external files on a subdomain already, allow nothing but that subdomain:

config/initializers/csp.rb

SecureHeaders::Configuration.default do |config|  
  config.csp = {
    report_only: Rails.env.production?, # default: false
    preserve_schemes: true, # default: false.
    default_src: %w('none'), # nothing allowed
    script_src: %w(cdn.example.com),
    connect_src: %w('self'),
    img_src: %w(cdn.example.com),
    style_src: %w('unsafe-inline' cdn.example.com),
    report_uri: ["/csp_report?report_only=#{Rails.env.production?}"]
  }
end  

Popular libraries and services

This is what you'll need to add to your CSP in the SecureHeaders config file if you use the following services:

  • G* Analytics: Add https://*.google-analytics.com to script_src. Or both https://www.google-analytics.com and https://ssl.google-analytics.com
  • jQuery on Google CDN: Add https://ajax.googleapis.com to script_src.
  • Bootstrap: Add https://maxcdn.bootstrapcdn.com to script_src and style_src.
  • Facebook Like button: Add https://www.facebook.com to child_src for the iframe approach and move the facebook-like-frame style to your own CSS.

About source URLs

  • If your site is HTTPS-only, you can leave out the https:// in all source URLs. That's because you already had to take care to use only https:// sources so there aren't any mixed-content warnings. Http:// sources in your site would also trigger a Content Security Policy warning because that's a downgrade. An upgrade from http:// (of your site) to https:// (the source) is possible though. That means script-src http://example.com/ is the same as script-src http://example.com https://example.com.
  • And if your site lives on http://, you can also leave out the http:// and https:// in all sources.

There's a preserve_schemes option in the secure_headers gem which removes all schemes from the source URLs.

Also make sure that the source hosts don't serve JSONP replies 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.

CSP exceptions

In order not to add payment-related domains to every page, you can add directives for specific controllers, for example for the PaymentsController. This is described in the named overrides of the Secure Headers gem.

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 of JavaScript.
style-src Allowed sources of CSS.
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 Deprecated, use child-src. However, not all browsers support child-src yet, so you might want to send both.
child-src Allowed sources for <frame> and <iframe>.
frame-ancestors Which parent sites may include this site in a <frame> or <iframe>. This replaces the X-Frame-Options header.
connect-src Allowed endpoints for Ajax, Websockets and HTML5 EventSource.
sandbox A list of flags to implement a sandbox to allow forms, 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 from the default source to the other source directives. So for example, script-src doesn't inherit anything from default-src.

Values

  • * as in img-src * allows any URL (except data:, blob: or file:)
  • 'none' as in object-src 'none' is the opposite, allows nothing
  • 'self' as in script-src 'self' allows scripts from the same origin (same scheme, host, and port)
  • data: as in img-src data: allows images via the data scheme (Base64 encoded images)
  • A URL as in img-src cdn.example.com allows images from that exact domain
  • 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 used for *.js.erb Ruby on Rails Ajax responses.

CSP violation reports

If you add a report-uri /csp_report?report_only=false rule, the browser will send the CSP violation reports to that endpoint. We're sending a report_only parameter with the value true if the resource was just reported, but not blocked (i.e. when the Content-Security-Policy-Report-Only header was used). This is for the controller endpoint to know which mode is currently used. 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 = JSON.parse(request.body.read)['csp-report']
    CspReport.create!(
      blocked_uri: report['blocked-uri'],
      document_uri: report['document-uri'],
      effective_directive: report['effective-directive'],
      ip: request.remote_ip,
      original_policy: report['original-policy'],
      report_only: params[:report_only] == 'true',
      referrer: report['referrer'],
      status_code: report['status-code'],
      user_agent: request.user_agent,
      violated_directive: report['violated-directive']
    )
    render nothing: true
  end
end  

This is the SecureHeaders configuration for the endpoint:

SecureHeaders::Configuration.default do |config|  
  config.csp = {
    ...
    report_only: Rails.env.production?, # default: false, 
    # deprecated, instead configure csp_report_only
    report_uri: ["/csp_report?report_only=#{Rails.env.production?}"]
  }
  config.csp_report_only = config.csp.merge({
    ...
    report_uri: ["/csp_report?report_only=#{Rails.env.production?}"]
  })
end  

And here’s the table migration:

class CreateCspReports < ActiveRecord::Migration  
  def change
    create_table :csp_reports do |t|
      t.text :document_uri
      t.text :referrer
      t.text :violated_directive
      t.text :effective_directive
      t.text :original_policy
      t.text :blocked_uri
      t.integer :status_code
      t.text :ip
      t.text :user_agent
      t.boolean :report_only

      t.timestamps
    end
  end
end  

And here's how the report’s JSON looked like when I injected a script from a disallowed source:

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

However, take good caution with the endpoint and the reports. An attacker might forge a report to make you visit a certain site from the report or to run a DoS attack. You might want to require the user to be logged in and rate-limit the controller.