The policy this app already has

Free preview 4 min to read

Most Rails applications have config/initializers/content_security_policy.rb sitting there entirely commented out. This one does not.

Rails.application.configure do
  config.content_security_policy do |policy|
    policy.default_src :self
    policy.font_src    :self, :https, :data, "https://fonts.bunny.net"
    policy.img_src     :self, :https, :data, :blob
    policy.object_src  :none
    # wasm-unsafe-eval is required for the ruby.wasm browser sandbox (WebAssembly.compile)
    policy.script_src  :self, :wasm_unsafe_eval, "https://cdn.jsdelivr.net"
    policy.style_src   :self, :unsafe_inline, "https://fonts.bunny.net", "https://cdn.jsdelivr.net"
    policy.connect_src :self, "https://cdn.jsdelivr.net", "https://ga.jspm.io", "https://fonts.bunny.net", :blob
    policy.worker_src  :self, :blob
  end

  config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
  config.content_security_policy_nonce_directives = %w[script-src]
end

Every line of that is a decision someone made, and this course is about reading it.

What a policy is

A response header telling the browser where resources may come from:

Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net; object-src 'none'

The browser enforces it. A <script src="https://evil.example/x.js"> injected into your page (by a stored XSS, a compromised dependency, a browser extension) does not load, and the console records a violation.

That is the framing worth holding: CSP is not a way to prevent XSS. It is a way to make an XSS not work. The injection still happens; the payload does not execute. It is defense in depth, and it is the layer that catches the bug you did not find.

flowchart TB
  X["An injection succeeds: script tag in the page"] --> B["Browser reads the CSP header"]
  B --> Q{"does script-src allow this source?"}
  Q -->|"no"| BL["not executed; violation reported"]
  Q -->|"yes"| RUN["executed — the CSP did not help here"]
  BL --> D["The XSS existed and did nothing"]

Reading this policy

Directive Value here Means
default_src :self Everything not otherwise specified: same origin only
font_src :self, :https, :data, bunny.net Fonts from anywhere over HTTPS, plus data URIs
img_src :self, :https, :data, :blob Images from anywhere over HTTPS, plus data and blob URLs
object_src :none No <object>, <embed> or applets at all
script_src :self, :wasm_unsafe_eval, jsdelivr Own scripts, WebAssembly compilation, and one CDN
style_src :self, :unsafe_inline, two CDNs Own styles, inline styles, and two CDNs
connect_src :self plus three hosts and :blob Where fetch and XMLHttpRequest may go
worker_src :self, :blob Where Web Workers may be loaded from

Two of these are unusual and both have a reason, which lesson 4 works through in detail: wasm_unsafe_eval exists for the ruby.wasm preview that shows learners their code's output in the browser (grading runs on the server). And unsafe_inline in style_src is the weakest line in the file.

The nonce

config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
config.content_security_policy_nonce_directives = %w[script-src]

A nonce is a token the server puts in both the header and the script tag. The browser runs an inline script only when the two match. So a script injected by an attacker (who cannot know the token) does not run even though it is inline.

In the layout:

<script nonce="<%= content_security_policy_nonce %>">
  if (navigator.serviceWorker) {
    navigator.serviceWorker.register("/service-worker", { scope: "/" });
  }
</script>

That is the mechanism working as designed: one inline script, allowed by name, with no unsafe-inline in script_src at all.

The generator deserves a note. request.session.id.to_s is the generator Rails itself suggests, and it means the nonce is stable for a session rather than fresh per response. The security property still holds against an external attacker, who does not have the session id. And it is weaker than a per-response random value, because anything that leaks the session id also leaks the nonce. Lesson 3 returns to this.

And csp_meta_tag

<%= csp_meta_tag %>

This emits the nonce in a meta tag so that JavaScript adding a script element at runtime (Turbo, importmap) can read it and mark what it creates as allowed. Without it, dynamically inserted scripts are blocked, and the only symptom is a console error and a feature that quietly does nothing.

What this course covers

  1. The policy this app already has (this lesson).
  2. Directives and the fallback chain.
  3. Nonces, hashes and strict-dynamic.
  4. The exceptions in this policy.
  5. Rolling out, and report-only mode.
  6. Beyond script-src.
  7. Putting it together: hardening this policy.

Key takeaways

  • This repository has an active CSP, unlike most Rails applications where the initializer is commented out.
  • CSP does not prevent an injection; it stops the injected payload from executing.
  • default_src :self is the fallback, and each specific directive overrides it for its resource type.
  • A nonce lets one inline script run by name; this policy uses it for the service-worker registration.
  • The nonce generator is session-scoped, which is Rails' own suggestion and weaker than a per-response value.
Lesson 1 of 7 · free

Keep going with lesson 2

Directives and the fallback chain

Explains how fetch directives replace default-src, which three directives have no fallback, and what source values mean.

A free account also gives you the quizzes, your progress and notes, and the discussion.

Type to start searching...