In this blog post, I will explore a new and more flexible way to configure the Content-Security-Policy header in a Ruby on Rails application.

If you are unfamiliar with the Content-Security-Policy HTTP header, then I'd recommend reading the MDN Web Docs to learn more.

Background

DocSpring's Content-Security-Policy (CSP) header was starting to get out of hand:

We had been using the CSP policy feature provided by a security monitoring tool called Sqreen. They would collect CSP reports whenever a resource was blocked, and they had a web interface that made it easy to add new rules. As you can see, our CSP started to grow quite long as we tried out new analytics and support tools, or added custom integrations for specific customers.

Sqreen was acquired by Datadog, and unfortunately they shut down the service in October 2022. I uninstalled the sqreen Ruby gem, and switched to using Sentry for security policy monitoring. I configured our existing CSP header in config/initializers/content_security_policy.rb. The DSL looks like this:

Rails.application.config.content_security_policy do |policy|
  policy.script_src :self, :https
  # ...

The DocSpring app has a few different areas that each have their own requirements, such as our home page, the core Rails app, and our React template editor. I wanted to define custom Content-Security-Policy headers for each of these parts, so that the header only included relevant rules for the current page.

I found out that you can call the content_security_policy method in your controllers, to override parts of the globally configured Content-Security-Policy header:

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

This wasn't documented very well and I thought there was still some room for improvement, so I came up with my own method of configuring the CSP header.

Better CSP Configuration

I decided to write my own ContentSecurityPolicy class to manage the policy, and I added a HasContentSecurityPolicy concern to my ApplicationController. The concern is fairly straightforward:

module HasContentSecurityPolicy
  extend ActiveSupport::Concern

  included do
    helper_method :content_security_policy
    before_action :configure_content_security_policy
    after_action :set_content_security_policy_header
  end

  def content_security_policy
    @content_security_policy ||= ContentSecurityPolicy.new
  end

  # Override this method in your controller to configure the content security policy.
  # Call `super` if you want to inherit the parent controller's policy.
  def configure_content_security_policy; end

  def set_content_security_policy_header
    response.headers.merge!(content_security_policy.to_h)
  end
end

This sets up my own content_security_policy instance that I can call in my controllers, views, or helpers. After the response is rendered, I add the Content-Security-Policy (or Content-Security-Policy-Report-Only) header in an after_action callback.

I decided to package up the code and publish a Ruby gem called better_content_security_policy. (You can find the code on GitHub.)

How to use the gem

Install the gem and add it to your application's Gemfile by running:

$ bundle add better_content_security_policy

Include the BetterContentSecurityPolicy::HasContentSecurityPolicy concern in your ApplicationController:

class ApplicationController < ActionController::Base
  include BetterContentSecurityPolicy::HasContentSecurityPolicy

Define a #configure_content_security_policy method in ApplicationController to configure your default Content-Security-Policy rules:

  def configure_content_security_policy
    content_security_policy.default_src :none
    content_security_policy.font_src :self
    content_security_policy.script_src :self
    content_security_policy.style_src :self
    content_security_policy.img_src :self
    content_security_policy.connect_src :self
    content_security_policy.prefetch_src :self

    content_security_policy.report_uri = "http://example.com/csp_reports"
    content_security_policy.report_only = true
  end

You can access the content_security_policy instance in your controller actions. You can call any of the methods more than once (such as script_src and img_src), and new sources will be appended to the existing policy. You can also access the content_security_policy instance in your views.

You can define a #configure_content_security_policy method in other controllers. Call super if you want to inherit your default configuration from ApplicationController. Otherwise, you can omit the call to super if you want to start from scratch with a new policy.

After the response has been rendered, an after_action callback will generate and add the Content-Security-Policy (or Content-Security-Policy-Report-Only) header.

Examples

Plausible Analytics

Here's a Haml view partial that I'm using to include the JavaScript code for Plausible Analytics:

-# app/views/layouts/_plausible_analytics.html.haml

- if PLAUSIBLE_ANALYTICS_HOST
  - content_security_policy.connect_src PLAUSIBLE_ANALYTICS_HOST
  - content_security_policy.script_src PLAUSIBLE_ANALYTICS_HOST
  = javascript_include_tag "#{PLAUSIBLE_ANALYTICS_HOST}/js/script.js", defer: true, data: { domain: local_assigns[:domain].presence || request.host }
  = javascript_tag nonce: true do
    window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }

Whenever this view partial is rendered, the connect-src and script-src directives will be automatically added to your Content-Security-Policy header. I really like being able to define the content_security_policy rules right next to the javascript_include_tag. It's great that they are only added if a Plausible Analytics host is configured and all the logic is in one place, so we can generate specific and accurate Content-Security-Policy headers for different environments, and for on-premise customers.

Gravatar Images

Here's an overridden helper method that I use to generate Gravatar image URLs, which automatically adds the required CSP rules:

  def gravatar_image_url(email, options = {})
    content_security_policy.img_src 'https://secure.gravatar.com'
    content_security_policy.img_src 'https://*.wp.com'
    super
  end
Note: It's fine to call this method multiple times. Any duplicate entries are automatically removed.

Summary

This is quite similar to the content_security_policy method from Rails, but I think my version has slightly better UX. I also like being able to call it from my action methods and views, instead of as a class method in the controller.

Please feel free to try out the gem if you think it might be helpful, and open an issue on GitHub if you have any questions or feedback. Thanks for reading!