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!