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.
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.