Securing Rails applications with Content Security Policy

Posted on 2022-10-27 by Karol Bąk Comments
ruby
rails
security

Introduction

Nowadays web application security is a crucial and unfortunately sometimes a bit neglected matter. Today, I’ll focus on Content Security Policy - a handy mechanism that can protect our app from XSS attacks. It’s not Rails-specific, it can (or even should) be implemented in every web application but Rails provides great helpers to make implementation even easier.

XSS attacks

XSS (Cross-site scripting) is probably one of the most common vulnerabilities in web applications. If our application is vulnerable, an attacker can inject malicious code into the victim’s browser, what can lead to many dangerous situations like, for example, session hijacking.

Let’s take a simple example - we have a blog with comments. The attacker creates a new comment with the following content:

Great article! <script>console.log('hacked!')</script>

If, for some reason, we won’t sanitize it while displaying the script will be executed in the browser of our visitors. By default, ERB keeps us safe and escapes HTML, so code:

<%= 'abc <script>alert("hi!")</script>' %>

Will be rendered as plain text, < will be replaced with &amp;lt;. Unfortunately, there are some cases when it’s not that easy:

<%== "<b>#{comment.username}</b>: #{comment.content}" %>

Here, some careless developer wanted to display the username bolded, did it in a pretty bad way, and as a result created a vulnerability! Both comment.username and comment.content aren’t escaped, because of <%== which was added to avoid escaping <b>. In this code, it’s pretty obvious but unfortunately, in many cases, it’s not.

Content Security Policy (CSP)

There are many ways to prevent XSS or reduce its impact but CSP is a very simple to implement mechanism that eliminates most of the vulnerabilities right away. Rails provides helpers which make implementation even easier.

CSP allows us to specify trusted sources for all external resources like images or scripts. Adding such resources from untrusted sources will result in blocking them by the browser.

In the newly created Rails app, we have a config/content_security_policy.rb file with basic configuration. It’s disabled by default but all we have to do is to uncomment the code in the file. In version 7.0.4 it will look like this (after uncommenting):

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
    # Specify URI for violation reports
    # policy.report_uri "/csp-violation-report-endpoint"
  end

  # Generate session nonces for permitted importmap and inline scripts
  config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
  config.content_security_policy_nonce_directives = %w(script-src)

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

From now, our app will add a following header to the response:

Content-Security-Policy: default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src 'self' https: 'nonce-26ea5aaa858b26e451c1d9209e031f0a'; style-src 'self' https:

Let’s take a closer look at what it does. We have a list of different types of resources and their trusted sources.

  • :self - allows resources from current origin - so if your page is under somedomain.com it will allow all resources within this domain.
  • :https - it will allow loading resources from any origin as long as it’s using HTTPS (HTTP requests will be blocked)
  • :data - data URIs (for example: data:image/gif;base64,R0lGODlhEAAQA...)
  • :none - don’t allow loading objects from any source

So looking at our config it will allow resources except for the object type (<object>, <embed>, and <applet>) to be loaded from our domain, HTTPS URLs, and Data URIs. As you can see it’s easy to customize it per resource type. All types that are not present in the CSP header will fall back to default_src. You can check the full list of supported directives here.

We’ll take a look at the rest of the config later. Let’s focus on script-src for now, since it’s the most important one when talking about XSS. Apart from blocking scripts from the insecure connections, it will block the following things:

  • inline <script> blocks
  • javascript in HTML attributes like onclick="doSomething()""
  • eval and similar javascript methods

Thanks to that the code from the previous example will be blocked, cool, right? These are pretty solid settings for the start but can be improved. The attacker can still inject code like <script src="https://my.evil.site.com/dirty-script.js"></script>.

Improving script-src directive

To minimalize the risk of vulnerabilities we can remove :https from script_src and provide a list of allowed domains instead. It may take some time, especially if you are using stuff like Google Analytics, Facebook pixels, etc. Keep in mind that subdomains are not included by default but you can use wildcards like '*.google-analytics.com'.

Blocked inline scripts may be a problem, sometimes we would like to use them for various reasons. We could use :unsafe_inline to allow them but it would significantly impact our security and to be honest make our policy pointless. Luckily, there is a better way to do this.

Nonce

If you take another look at the generated header you’ll see the following content in the script-src part:

'nonce-26ea5aaa858b26e451c1d9209e031f0a'

It tells the browser to accept inline script with the following nonce value:

<script nonce="26ea5aaa858b26e451c1d9209e031f0a">
  console.log('hello')
  // script will be executed because it has correct noonce value
</script>

To keep it safe nonce should be random (so the attacker won’t be able to guess it). By default, Rails uses session id as a nonce but I recommend changing it.

config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }

Now nonce will be different on each request making it even safer. Using session id has some advantages - it can be useful if you are using Conditional GET caching (random noonce will prevent pages from being cached) but in most cases keeping it random is the best way to go.

To use it for your inline script just add nonce: true to the javascript_tag helper:

<%= javascript_tag(nonce: true) do %>
  console.log('hello')
<% end %>

Adding CSP to existing application

Adding CSP to an existing application can be painful, especially if it’s a large app with a lot of javascript. To do that without breaking anything we can use reporting feature.

First, let’s uncomment the following line:

policy.report_uri "/csp-violation-report-endpoint"

It orders the browser to send policy violation reports to /csp-violation-report-endpoint. You’ll have to implement such endpoint and store reports somewhere - in DB or anywhere else. The browser will send POST requests with a simple JSON body. It will include information about which rule was violated as well as blocked resource URLs.

Keeping it like that will still result in blocking such resources but we can change that as well, by uncommenting:

config.content_security_policy_report_only = true

Now, we will receive notifications about violations but they won’t be blocked.

Thanks to that we can easily integrate CSP into existing apps. First we setup our policy in report-only mode, we gather reports, fix our policy (by adding missing URLs) or our code (by adding nonce to inline scripts) and finally, we can turn off report-only mode.

It’s worth mentioning that sometimes you’ll receive weird violation reports not really related to your app - they are the results of different browser add-ons or extensions, you can safely ignore them.

Summary

So to summarize - CSP is a great mechanism that can improve the security of your app with relatively simple implementation. It can block most of the potential XSS attacks by disabling the execution of scripts from untrusted sources. Although, you should still sanitize/escape user inputs since it doesn’t protect us from injecting HTML.

If you’ll like to know all CSP directives and features please take a look at MDN.

Let me know if you’ll like to see more security-related articles!