The policy this app already has
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
- The policy this app already has (this lesson).
- Directives and the fallback chain.
- Nonces, hashes and
strict-dynamic. - The exceptions in this policy.
- Rolling out, and report-only mode.
- Beyond
script-src. - 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 :selfis 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.
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.