Rails.application.config.content_security_policy do |policy|
policy.default_src :self
policy.font_src :self, :https, :data
policy.img_src :self, :https, :data, "https://cdn.example.com"
policy.object_src :none
policy.script_src :self, :https
policy.style_src :self, :https
policy.connect_src :self, "https://api.example.com"
policy.frame_ancestors :none
policy.base_uri :self
policy.form_action :self
policy.report_uri "/csp-reports"
end
Rails.application.config.content_security_policy_nonce_generator = lambda do |request|
request.session.id.to_s.presence || SecureRandom.base64(16)
end
Rails.application.config.content_security_policy_nonce_directives = %w[script-src style-src]
# Deploy in observe-only mode until reports are clean, then flip the env var.
Rails.application.config.content_security_policy_report_only = ENV.fetch("CSP_REPORT_ONLY", "true") == "true"
class ApplicationController < ActionController::Base
before_action :permit_nonce_for_turbo
helper_method :current_csp_nonce
content_security_policy_report_only only: :landing
def landing
render :landing
end
private
def current_csp_nonce
content_security_policy_nonce
end
# Ensure Turbo and importmap inline shims share the request nonce.
def permit_nonce_for_turbo
request.content_security_policy_nonce ||= content_security_policy_nonce
end
end
class CspReportsController < ActionController::Base
skip_before_action :verify_authenticity_token, raise: false
IGNORED_SCHEMES = %w[chrome-extension moz-extension safari-extension about].freeze
def create
report = parse_report
return head(:no_content) if report.blank? || ignored?(report)
payload = {
directive: report["violated-directive"],
blocked_uri: report["blocked-uri"],
document: report["document-uri"],
referrer: report["referrer"].presence,
user_agent: request.user_agent
}
Rails.logger.warn("[csp-violation] #{payload.to_json}")
CspViolationJob.perform_later(payload)
head :no_content
end
private
def parse_report
body = request.body.read
return {} if body.blank?
JSON.parse(body).fetch("csp-report", {})
rescue JSON::ParserError
{}
end
def ignored?(report)
uri = report["blocked-uri"].to_s
IGNORED_SCHEMES.any? { |scheme| uri.start_with?(scheme) }
end
end
Content Security Policy is a browser-enforced allowlist that mitigates cross-site scripting by declaring which sources of scripts, styles, and other resources a page may load. This snippet shows a pragmatic starter that layers a nonce-based inline-script policy on top of Rails' built-in ActionDispatch::ContentSecurityPolicy DSL and wires up a violation-report endpoint.
The csp initializer configures the global policy via config.content_security_policy. Defaults are locked to :self, object-src is fully disabled, and frame-ancestors blocks framing to prevent clickjacking. The interesting part is config.content_security_policy_nonce_generator, which produces a fresh random nonce per request keyed off the session id, and content_security_policy_nonce_directives, which tells Rails to inject nonce-... into the script-src and style-src directives. This is what lets specific inline <script> tags run while everything else inline is blocked, avoiding the blunt and dangerous 'unsafe-inline'. Setting content_security_policy_report_only from an env var allows rolling the policy out in observe-only mode first, so violations are logged without breaking the site — the standard way to deploy CSP safely.
Because the nonce changes each request, any inline tag must reference content_security_policy_nonce. In ApplicationController a before_action calls permit_nonce_for_turbo, exposing the nonce as a helper so views and the layout can emit <%= javascript_tag nonce: true %> correctly, and csp_meta_tag keeps Turbo and importmap scripts trusted. The controller also flips to report-only mode for a marketing route where a looser policy is tolerable.
The CspReportsController receives the browser's application/csp-report POST bodies. create parses the nested csp-report object, extracts violated-directive and blocked-uri, and forwards a compact structured hash to logging and an async CspViolationJob for aggregation, then returns 204 No Content. Note skip_before_action :verify_authenticity_token and skip_before_action :require_login, since reports arrive unauthenticated and without a CSRF token. A subtle pitfall handled here is deduping noisy browser-extension violations by ignoring blocked-uri values that look like extension schemes. Together these files give a policy that is strict by default, observable through reports, and compatible with Rails' nonce machinery.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.