Updated 2018: The full Content Security Policy guide for Rails 5.2 (and earlier) apps.
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;
upgrade-insecure-requests
directiveIt 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 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.
I’ll call it a legacy Rails app if:
More recent Rails apps likely use a different JavaScript architecture:
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:
object_src
) allowed'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.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.
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'
.
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
.
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.
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:
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:
<meta>
tag.'unsafe-inline'
is still pretty common.data:
pseudo-protocolbase-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:
https://www.google-analytics.com
to script-src
and img-src
for the default pixel image tracking.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.https://maxcdn.bootstrapcdn.com
to script-src
and style-src
.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
.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.
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.http://
only, you can also leave out the protocol in all sources. No source will ever be a downgrade.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.
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))
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.
*
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)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
.script-src *.example.com
allow scripts from all subdomains on that domainimg-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.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.