How to Build a Bulletproof Content Security Policy for Rails Applications in 2024

Learn to implement Content Security Policy in Rails apps with dynamic configurations, nonce handling, and violation reporting. Boost security without breaking functionality.

How to Build a Bulletproof Content Security Policy for Rails Applications in 2024

Content Security Policy, or CSP, is a powerful security feature for your Rails applications. Think of it as a set of very specific rules you give to a user’s browser. These rules tell the browser exactly where it is allowed to load different types of content from—things like JavaScript, images, or fonts. The main goal is to stop a common attack called cross-site scripting, where malicious scripts are injected into your web pages.

Getting started with a basic CSP is simple, but modern applications are complex. They use third-party services, dynamic content, and complex front-end frameworks. A rigid, one-size-fits-all policy will often break your app’s features. The challenge is to build a policy that is both strong and flexible. I want to share some approaches that have worked well, moving from basic setups to more advanced configurations.

Let’s begin with the foundation: generating your policy dynamically. Instead of a static string in a config file, you can build it based on the current request and environment. This allows different rules for development, where you might need more flexibility, and production, where security is paramount.

Here is a basic middleware example that constructs a policy for each request.

class ContentSecurityPolicy
  def initialize(app)
    @app = app
  end

  def call(env)
    request = Rack::Request.new(env)
    status, headers, response = @app.call(env)
    
    policy = build_policy(request)
    headers['Content-Security-Policy'] = policy
    
    [status, headers, response]
  end
  
  private
  
  def build_policy(request)
    directives = {
      default_src: %w['self'],
      script_src: script_sources(request),
      style_src: style_sources(request),
      img_src: %w['self' data: https:],
      connect_src: connect_sources(request),
      font_src: %w['self' https://fonts.gstatic.com],
      object_src: %w['none'],
      media_src: %w['self'],
      frame_src: %w['none']
    }
    
    directives.map do |directive, sources|
      "#{directive.to_s.gsub('_', '-')} #{sources.join(' ')}"
    end.join('; ')
  end
  
  def script_sources(request)
    sources = %w['self']
    sources << "'nonce-#{request.env['csp_nonce']}'" if request.env['csp_nonce']
    sources << 'https://cdn.example.com' if Rails.env.production?
    sources
  end
end

In this code, the build_policy method creates a hash of directives. The script_sources method is where the logic lives. It always allows scripts from the app’s own origin ('self'). It adds a nonce if one is present on the request, which we’ll talk about next. It also adds a production CDN only when in that environment. This pattern lets you adapt your security rules contextually.

Managing CSP headers directly can become messy. A popular and effective tool is the secure_headers gem. It provides a clean, centralized way to define and manage your policies. You can set a default configuration and then modify it in specific controllers or actions.

Here is how you might configure it in an initializer.

# config/initializers/secure_headers.rb
SecureHeaders::Configuration.default do |config|
  config.csp = {
    default_src: %w['self'],
    script_src: %w['self' 'unsafe-inline' 'unsafe-eval' https:],
    style_src: %w['self' 'unsafe-inline' https:],
    img_src: %w['self' data: https:],
    font_src: %w['self' https: data:],
    connect_src: %w['self' wss:],
    report_uri: ['/csp_reports']
  }
  
  config.csp_report_only = config.csp.merge(
    script_src: %w['self' https: 'unsafe-inline' 'unsafe-eval'],
    report_only: true
  ) if Rails.env.development?
end

The gem also lets you run in report-only mode. In the development configuration above, the policy is merged with a report_only: true flag. This means the browser will monitor the policy and report violations back to your /csp_reports endpoint, but it won’t actually block anything. This is incredibly useful for testing. You can see what would break without breaking it, allowing you to refine the policy safely.

You can also override the policy for specific controllers. For instance, an analytics dashboard might need to connect to an external service.

class AnalyticsController < ApplicationController
  def show
    csp_config = current_csp.merge(
      script_src: current_csp[:script_src] + ['https://analytics.example.com'],
      connect_src: current_csp[:connect_src] + ['https://api.analytics.example.com']
    )
    apply_csp(csp_config)
  end
end

One of the biggest hurdles with CSP is dealing with inline scripts and styles. A strict policy forbids them because they are a common attack vector. However, most Rails applications have some inline JavaScript, like a configuration object set by a view. The solution is to use a “nonce,” which stands for “number used once.”

A nonce is a random, unique string generated for each page request. You add it to your CSP header, and you also add it as an attribute to any inline script or style tag. The browser will only execute the inline content if the nonce in the tag matches the one in the header.

First, you need to generate and attach the nonce to the request. A controller callback is a good place.

class ApplicationController < ActionController::Base
  before_action :set_csp_nonce
  
  private
  
  def set_csp_nonce
    request.env['csp_nonce'] = SecureRandom.base64(16)
  end
end

Next, create helper methods to make using the nonce in views straightforward.

module CspNonceHelper
  def csp_nonce
    @csp_nonce ||= SecureRandom.base64(16)
  end
  
  def nonce_meta_tag
    tag.meta(name: 'csp-nonce', content: csp_nonce)
  end
  
  def javascript_tag_with_nonce(content, **options)
    javascript_tag(content, nonce: csp_nonce, **options)
  end
  
  def stylesheet_tag_with_nonce(content, **options)
    stylesheet_tag(content, nonce: csp_nonce, **options)
  end
end

In your layout, you output a meta tag with the nonce. This can be read by more complex JavaScript frameworks.

<%= nonce_meta_tag %>

Then, for any inline script, you use the helper.

<%= javascript_tag_with_nonce do %>
  window.config = <%= raw frontend_config.to_json %>;
<% end %>

This pattern securely allows the inline script because it carries the valid, session-specific nonce.

Even with careful planning, you will miss something. A policy might block a legitimate resource. To catch these issues, you must listen for violation reports from browsers. Setting up a reporting endpoint is crucial for maintaining and tuning your CSP.

Here is a simple controller to handle those reports.

class CspReportsController < ApplicationController
  skip_forgery_protection
  
  def create
    report = parse_report(request.body.read)
    
    if report[:violated_directive]
      log_violation(report)
      notify_developers(report) if severe_violation?(report)
    end
    
    head :no_content
  end
  
  private
  
  def parse_report(raw_report)
    data = JSON.parse(raw_report)
    {
      document_uri: data['document-uri'],
      violated_directive: data['violated-directive'],
      original_policy: data['original-policy'],
      blocked_uri: data['blocked-uri'],
      source_file: data['source-file'],
      line_number: data['line-number'],
      user_agent: request.user_agent,
      ip_address: request.remote_ip
    }
  end
  
  def log_violation(report)
    Rails.logger.warn "CSP Violation: #{report[:violated_directive]} blocked #{report[:blocked_uri]}"
  end
end

This endpoint accepts a POST request (with forgery protection skipped, as it’s from external browsers), parses the JSON report, and logs it. You can add more logic to filter noise or send alerts for a sudden spike in reports, which could indicate a new feature is broken or an attack is in progress.

Most applications rely on third-party services like payment processors, maps, or chat widgets. Each service needs to load scripts, frames, or make connections from its own domains. Hard-coding all these into your main policy is messy and can lead to overly permissive rules.

A cleaner method is to define CSP rules for each integration and merge them only when needed.

class ThirdPartyCspManager
  INTEGRATIONS = {
    google_maps: {
      script_src: ['https://maps.googleapis.com'],
      frame_src: ['https://www.google.com'],
      style_src: ["'unsafe-inline'"]
    },
    stripe: {
      script_src: ['https://js.stripe.com'],
      connect_src: ['https://api.stripe.com'],
      frame_src: ['https://js.stripe.com']
    },
    intercom: {
      script_src: ['https://widget.intercom.io'],
      connect_src: ['https://api-iam.intercom.io'],
      img_src: ['https://static.intercomassets.com']
    }
  }
  
  def self.policy_for(integration_name, base_policy)
    integration = INTEGRATIONS[integration_name.to_sym]
    return base_policy unless integration
    
    base_policy.deep_merge(integration) do |key, base_value, integration_value|
      (Array(base_value) + Array(integration_value)).uniq
    end
  end
end

Then, in a controller action that uses Stripe, you would apply it specifically.

class PaymentsController < ApplicationController
  def new
    csp = current_csp
    csp = ThirdPartyCspManager.policy_for(:stripe, csp)
    apply_csp(csp)
  end
end

This keeps your default policy tight and only adds external sources for the pages that truly need them. The deep_merge block ensures sources for the same directive, like script_src, are combined into a single array.

Nonces are perfect for dynamic inline content, but what about your static assets, like the application.js compiled by the asset pipeline? For these, you can use an alternative method: cryptographic hashes. You compute a hash of the file’s content and add that hash to your CSP directive. The browser will only execute a script if its content matches the hash.

You can precompute these hashes at deployment time and inject them into your CSP header or HTML.

class AssetHashCsp
  def initialize(app)
    @app = app
    @asset_hashes = precompute_asset_hashes
  end
  
  def call(env)
    status, headers, response = @app.call(env)
    
    if html_response?(headers)
      body = response_body(response)
      updated_body = inject_asset_hashes(body)
      response = [updated_body]
      headers['Content-Length'] = updated_body.bytesize.to_s
    end
    
    [status, headers, response]
  end
  
  private
  
  def inject_asset_hashes(body)
    @asset_hashes.each do |asset_path, hashes|
      body.gsub!(asset_path) do |match|
        "#{match}'sha256-#{hashes[:sha256]}' 'sha384-#{hashes[:sha384]}'"
      end
    end
    body
  end
  
  def precompute_asset_hashes
    Dir.glob(Rails.root.join('public/assets/**/*.{js,css}')).each_with_object({}) do |path, hashes|
      content = File.read(path)
      hashes[File.basename(path)] = {
        sha256: Digest::SHA256.base64digest(content),
        sha384: Digest::SHA384.base64digest(content)
      }
    end
  end
end

This middleware finds all compiled assets, calculates their SHA-256 and SHA-384 hashes, and stores them. When serving an HTML response, it finds references to those assets and appends the hash directives. This is a more advanced setup, but it allows for a very strict policy that doesn’t rely on nonces for static files.

Finally, collecting violation reports is one thing; understanding them is another. As your application grows, you need a way to analyze these reports to spot trends, common misconfigurations, or potential attacks.

A simple analytics class can process the reports you log.

class CspAnalytics
  def initialize
    @violations = []
    @trends = Hash.new { |h, k| h[k] = [] }
  end
  
  def record_violation(report)
    @violations << report
    update_trends(report)
    
    if violation_rate_exceeded?
      alert_security_team(report)
    end
  end
  
  def violation_summary
    {
      total: @violations.count,
      by_directive: violations_by_directive,
      by_resource: violations_by_resource,
      trends: @trends
    }
  end
  
  private
  
  def violations_by_directive
    @violations.group_by { |v| v[:violated_directive] }
               .transform_values(&:count)
  end
  
  def violations_by_resource
    @violations.group_by { |v| v[:blocked_uri] }
               .transform_values(&:count)
               .sort_by { |_, count| -count }
               .first(10)
  end
  
  def violation_rate_exceeded?
    recent_violations = @violations.select { |v| v[:timestamp] > 1.hour.ago }
    recent_violations.count > 100
  end
end

You could call record_violation from your CspReportsController. The violation_summary method gives you a clear view of what’s being blocked most often. The violation_rate_exceeded? logic provides a basic alert system; a sudden flood of reports might mean a new deployment has a bug, or it could signal an automated attack probing for weaknesses.

Implementing a strong Content Security Policy is an ongoing process, not a one-time setup. Start with a report-only policy, use the patterns for dynamic generation and nonces, integrate third-party services carefully, and always listen to the violation reports. These steps will help you build a Rails application that is not only functional but also significantly more resilient against a range of common web attacks. The code is a starting point; adapt it to fit the specific architecture and needs of your project.


// Keep Reading

Similar Articles

TracePoint: The Secret Weapon for Ruby Debugging and Performance Boosting
Ruby

TracePoint: The Secret Weapon for Ruby Debugging and Performance Boosting

TracePoint in Ruby is a powerful debugging tool that allows developers to hook into code execution. It can track method calls, line executions, and exceptions in real-time. TracePoint is useful for debugging, performance analysis, and runtime behavior modification. It enables developers to gain deep insights into their code's inner workings, making it an essential tool for advanced Ruby programming.

Read Article →