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.