Content Security Policy (CSP) API in Rails

How to Implement Content Security Policy in Rails

This article shows how to implement content security policy in your Rails applications to protect against cross-site scripting (XSS) vulnerability. We'll also learn how you can report CSP violations without enforcing the policy and make exceptions for inline scripts with nonce attributes.

8 min read

Cross-site Scripting (XSS) is one of the most common security vulnerabilities in web applications. In this article, we'll learn how to implement a strict content security policy in your Rails applications to protect against XSS attacks.

This post only covers CSP in the context of Rails apps. It won't make any sense if you don't understand what a content security policy is and what problem it's solving. For a comprehensive, framework-agnostic overview, check out my previous article: What Every Web Developer Must Know About Content-Security Policy
Content Security Policy (CSP): Everything You Should Know
This is a comprehensive guide to Content Security Policy (CSP). If you build websites for a living, CSP is an important concept to know, understand, and implement to protect your users from Cross-Site Scripting (XSS) Injection attacks. This post covers (almost) everything you need to know about CSP.

In its essence, the goal of a content security policy is to prevent inline scripts and styles as well as to provide valid list of external sources from which to fetch resources such as scripts, styles, fonts, plugins, etc.

To implement CSP in your applications, you have to set a special HTTP header called Content-Security-Policy.

Content-Security-Policy: default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'

As you can see, the header value is long and messy. As you add more policies, it gets quite clunky to read and understand. To fix this, Rails provides a convenient API that lets you create this header in an elegant manner.

Content Security Policy API in Rails

When you create a new Rails application, Rails creates an initializer file named content_security_policy.rb under the config directory. By default, it's commented out. To enable CSP, uncomment it.

Here's an example configuration that sets up content security policy in Rails.

# config/initializers/content_security_policy.rb

Rails.application.configure do
  config.content_security_policy do |policy|
    policy.default_src :self, :https
    policy.font_src    :self, :https, :data
    policy.img_src     :self, :https, :data
    policy.object_src  :none
    policy.script_src  :self, :https
    policy.style_src   :self, :https
  end
end

As you can see, it's a custom DSL (domain-specific language) written in pure Ruby. You're simply calling various methods (named after the policy directives) on the policy object and passing the policy values as method arguments. For example, consider the statement:

policy.script_src  :self, :https

It instructs Rails to insert the script-src directive in the policy header and set its values to self and https, which restrict the script sources to HTTPS over the same origin.

💡
I've added a complete list of directive methods along with their values in the appendix, at the bottom of this page.

After enabling the CSP, if you launch this app in the browser and inspect the network tab, you'll see the Content-Security-Policy header, with all the policy directives we set up in the initializer.

Inspecting Headers in DevTools
Inspecting Headers in DevTools

Any time you make a change in the CSP configuration, you have to restart the application, since Rails only configures it at the start.

By the way, if you're curious about how initializers work in Rails, check out this article:

A Brief Introduction to Rails Initializers: Why, What, and How
At first glance, Rails initializers seem complex, but they’re solving a simple, but important problem: run some code after framework and gems are loaded, to initialize the application. This post covers the basics of initializers, including what they are, how they work, and how Rails implements them.

Reporting CSP Violations without Enforcing Them

Although it sounds nice in theory to implement a strict policy, reality is a bit messy. Often you have a lot of legacy code and often you don't know what JavaScript you're using, let alone if it's inline and/or loaded from external sources.

In these cases, you probably don't want to enforce a strict policy right away. Most likely, it will break your application as the browsers won't load any external sources or inline scripts.

As a solution, the recommended best practice is to enable the content security policy in 'report-only' mode, using the Content-Security-Policy-Report-Only header. This lets you observe the CSP violations in a safe mode, knowing that it won't break your app.

In Rails, you can enable the report-only mode by setting the content_security_policy_report_only config property to true.

# config/initializers/content_security_policy.rb

Rails.application.configure do
  config.content_security_policy do |policy|
    policy.script_src  :self, :https
    policy.style_src   :self, :https
  end

  # Report violations without enforcing the policy.
  config.content_security_policy_report_only = true
end

Once the report-only mode is enabled, the browser will still load any external and inline scripts. However, it will post policy violation reports (which rule was violated and which URLs would've been blocked) to a URL endpoint.

You can either implement this endpoint yourself and store the reports in a database, or you can use a third-party service like report-uri.com.

This lets you implement a strict content security policy gradually, over time. First, you enable it in report-only mode and gather reports. Then you fix the policy either by

  1. fixing the violations, e.g. replacing inline code with proper scripts with src
  2. allowing certain external / inline scripts with nonce attributes or
  3. adding new directives to further tighten the policy

Once you're happy with the current state of the policy and no more violations are being reported, you can turn off the report-only mode to enable and enforce the strict policy.

Note: Rails also supports the report-uri directive, which lets you enforce and report in the same command. However, it's deprecated and no longer recommended.
Rails.application.config.content_security_policy do |policy|
  policy.report_uri "/csp-violation-report-endpoint"
end

Making Exceptions with Nonce Attributes

There will be times when you can't just remove certain scripts or styles that are either inline or fetched from external resources.

When you have to make exceptions to the strict CSP, you've two options:

Option One (not recommended): Use the unsafe-inline directive to allow use of inline scripts. This setting bypasses the content security policy entirely. For example, the following policy allows all inline scripts.

Content-Security-Policy: script-src 'unsafe-inline'

This option is not recommended as it leaves your application open to XSS attacks.

Option Two (recommended): Use the nonce attribute to allow specific inline scripts or styles, while blocking all other inline scripts and styles. This lets you keep the essencial benefits of CSP while still making much-needed exceptions to the policy.

You can use nonce attributes in Rails by setting the value of the content_security_policy_nonce_generator property to a lambda.

# config/initializers/content_security_policy.rb

Rails.application.configure do
  config.content_security_policy do |policy|
    policy.script_src  :self, :https
    policy.style_src   :self, :https
  end

  # Generate session nonces for permitted inline scripts and styles.
  config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
end

A few points to note:

  • The lambda accepts the incoming HTTP request (to allow you to generate a unique nonce token for each request).
  • In the body of the lambda, you generate a new token that's guaranteed to be unique.

Then, you add the same nonce token to all the inline script and style tags which you want to allow.

<%= javascript_tag nonce: true do -%>
  alert('Hello, World!');
<% end -%>

<%= javascript_include_tag "script", nonce: true %>

Restart your Rails application and inspect the headers. You will notice that Rails has added a nonce value in the CSP header, and the browser allowed the inline JavaScript.

nonce on CSP header
nonce on CSP header

Now open the response tab and inspect the HTTP response. Notice the inline script element has a nonce attribute with the same value as the one in the header.

nonce attribute on script tag
nonce attribute on script tag

If you had another script element on the same page without the nonce attribute, the browser won't execute that script.

So far, we've implemented the policy for the whole application. That means all parts of your application are protected with the same policy. What if you want to loosen up or tighten the policy for a particular resource (or controller)?

How to Override Content Security Policy in a Rails Controller

Rails allows you to change the policy for a particular controller:

class PostsController < ApplicationController
  content_security_policy do |policy|
    policy.base_uri "https://www.example.com"
  end
end

If you want to disable it completely (for a controller), you can.

class LegacyArticlesController < ApplicationController
  content_security_policy false, only: :index
end

If you want to restrict the sources to sub-domains, pass a block that will be executed when adding that specific policy directive.

class PostsController < ApplicationController
  content_security_policy do |policy|
    policy.base_uri :self, -> { "https://#{current_user.domain}.example.com" }
  end
end

This is especially useful for multi-tenant applications.

This wraps up our brief exploration into the CSP API in Rails. I hope it was useful and you learned something new.


If you're new to the blog, check out the full archive to see all the posts I've written so far or the favorites page for the most popular articles on this blog.

As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I look forward to hearing from you.

If you'd like to receive future articles directly in your email, please subscribe to my blog. If you're already a subscriber, thank you.


Appendix

Here's the complete mapping of directive methods to the actual CSP directives.

DIRECTIVES = {
  base_uri:                   "base-uri",
  child_src:                  "child-src",
  connect_src:                "connect-src",
  default_src:                "default-src",
  font_src:                   "font-src",
  form_action:                "form-action",
  frame_ancestors:            "frame-ancestors",
  frame_src:                  "frame-src",
  img_src:                    "img-src",
  manifest_src:               "manifest-src",
  media_src:                  "media-src",
  object_src:                 "object-src",
  prefetch_src:               "prefetch-src",
  require_trusted_types_for:  "require-trusted-types-for",
  script_src:                 "script-src",
  script_src_attr:            "script-src-attr",
  script_src_elem:            "script-src-elem",
  style_src:                  "style-src",
  style_src_attr:             "style-src-attr",
  style_src_elem:             "style-src-elem",
  trusted_types:              "trusted-types",
  worker_src:                 "worker-src"
}

source

And here're the values you can use for the above directives:

MAPPINGS = {
  self:             "'self'",
  unsafe_eval:      "'unsafe-eval'",
  unsafe_hashes:    "'unsafe-hashes'",
  unsafe_inline:    "'unsafe-inline'",
  none:             "'none'",
  http:             "http:",
  https:            "https:",
  data:             "data:",
  mediastream:      "mediastream:",
  allow_duplicates: "'allow-duplicates'",
  blob:             "blob:",
  filesystem:       "filesystem:",
  report_sample:    "'report-sample'",
  script:           "'script'",
  strict_dynamic:   "'strict-dynamic'",
  ws:               "ws:",
  wss:              "wss:"
}

source