ruby / csp

A Content Security Policy header tells the browser which sources are allowed to load scripts, styles, frames, and other resources. A tight policy is the last line of defense against XSS and injection in third-party content.

The middleware article shows the Rack middleware that sets the header. This article is about the policy itself.

A tight policy

Start from default-deny and list only what the app actually loads:

[
  "default-src 'self'",
  "base-uri 'none'",
  "object-src 'none'",
  "frame-ancestors 'none'",
  "script-src 'self'",
  "style-src 'self' 'unsafe-inline'",
  "img-src 'self' data: https://your-image-cdn.example",
  "connect-src 'self'"
].join("; ")

Every directive names specific origins. Each new external host (an analytics script, a third-party widget) is a deliberate addition rather than an automatic one.

Pitfalls to avoid

'unsafe-eval' enables eval, new Function, and setTimeout called with a string body. A modern app does not need any of those. Allowing them widens the impact of any XSS that lands.

A wildcard like https: in script-src accepts any HTTPS origin. That undoes most of CSP's value and turns the directive into documentation rather than enforcement. List specific hosts; if there are none, 'self' is enough.

'unsafe-inline' in script-src allows inline <script> tags and event handlers. Inline scripts are the most common XSS vector. Move them to files served from your origin, or use a nonce.

Nonces for inline

If a small inline script is unavoidable (for example, to bridge server-rendered data into JS), generate a per-response nonce:

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

  def call(env)
    nonce = SecureRandom.base64(16)
    env["app.csp_nonce"] = nonce

    status, headers, body = @app.call(env)

    if headers["Content-Type"].to_s.include?("text/html")
      headers["Content-Security-Policy"] = [
        "base-uri 'none'",
        "object-src 'none'",
        "script-src 'self' 'nonce-#{nonce}'",
        "style-src 'self' 'unsafe-inline'"
      ].join("; ")
    end

    [status, headers, body]
  end
end

The handler exposes the nonce as a default local so templates can reference it:

%script{nonce: csp_nonce, type: "application/json", id: "boot"}
  != bootstrap_data_json

The browser executes the inline tag only if its nonce matches the response header.

Report-only first

When tightening a policy, ship a Content-Security-Policy-Report-Only header for a release before enforcing. The header is identical; the browser sends violation reports instead of blocking. Read the reports, fix the genuine sources, then flip the header name.

Tests

A request test checks the header on every HTML response:

def test_csp_excludes_unsafe_eval
  resp = get("/")
  csp = resp.headers["Content-Security-Policy"].to_s
  ok { csp.include?("script-src 'self'") }
  ok { !csp.include?("unsafe-eval") }
  ok { !csp.match?(/script-src[^;]*\bhttps:\B/) }
end

A regression that adds unsafe-eval to silence a console error fails the test before it ships.

← All articles