Rails Content Security Policy: CSP-headers, Nonces en Turbo-compatibiliteit
Rails content security policy: configureer CSP-headers, genereer nonces voor Turbo en Stimulus, los schendingen op en deploy zonder je app te breken. Volledige gids.
De staging-deploy ging prima. Productie faalde meteen.
Geen crash — erger. De pagina laadde, maar niets werkte. Geen dropdowns, geen Turbo frame-navigatie, geen Stimulus-controllers die reageerden. De browserconsole was een muur van rood: Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'". Iemand had het CSP-header in de load balancer-configuratie over het weekend ingeschakeld als onderdeel van een security-audit — en ze hadden het goed gedaan. Wat betekende dat alles wat we met inline scripts hadden gebouwd nu stil dood was.
Na negentien jaar Rails heb ik dit scenario een dozijn keer zien gebeuren. CSP is niet moeilijk, maar het straft je meteen en volledig als je het verkeerd configureert. Je moet weten wat Turbo, Stimulus, Action Cable en je asset pipeline daadwerkelijk in de DOM injecteren voordat je een policy kunt schrijven die werkt.
Dit is die gids.
Wat Rails Content Security Policy Is en Waarom Het Uitmaakt
Rails content security policy is een HTTP-antwoordheader die browsers vertelt welke bronnen van inhoud — scripts, stijlen, afbeeldingen, lettertypen, WebSocket-verbindingen — ze mogen laden. Alles wat niet op de lijst staat, wordt geblokkeerd voordat het wordt uitgevoerd.
Vanuit een beveiligingsperspectief is CSP de laatste verdedigingslinie tegen cross-site scripting. Als een aanvaller <script>alert(document.cookie)</script> in je pagina injecteert, blokkeert een goed geconfigureerde CSP dat script ook al slaagt de injectie zelf. Het voorkomt de injectie niet — dat is wat invoervalidatie en geparametriseerde queries voor zijn — maar het beperkt de schade van een XSS-kwetsbaarheid tot ruis in plaats van gestolen credentials.
Rails heeft een ingebouwde CSP-DSL meegeleverd sinds versie 5.2. Je hebt geen gem nodig.
Rails Content Security Policy Configureren
De configuratie staat in config/initializers/content_security_policy.rb. Een generator maakt het bestand aan:
bin/rails generate content_security_policy
Het standaard gegenereerde bestand is uitgecommentarieerd. Hier is een werkende basis voor een moderne Rails 8-app met Turbo en Stimulus:
# config/initializers/content_security_policy.rb
Rails.application.configure do
config.content_security_policy do |policy|
policy.default_src :self, :https
policy.font_src :self, :https, :data
policy.img_src :self, :https, :data, :blob
policy.object_src :none
policy.script_src :self
policy.style_src :self, :https
policy.connect_src :self, :https
# WebSocket-verbindingen voor Action Cable toestaan
# Vervang door je eigen domein in productie
policy.connect_src :self, :https, "wss://jouwapp.nl"
# Schendingen rapporteren aan je endpoint
policy.report_uri "/csp_violation_reports"
end
# Nonces inschakelen voor inline script- en style-tags (vereist voor Turbo)
config.content_security_policy_nonce_generator = ->(_request) {
SecureRandom.base64(16)
}
config.content_security_policy_nonce_directives = %w[script-src style-src]
end
De nonce_generator is het cruciale onderdeel. Turbo en Rails injecteren allebei inline <script>-tags — voor dingen zoals Turbo’s data-islands en csrf_meta_tags. Een nonce is een cryptografisch token per request dat aan de CSP-header én aan elke inline scripttag wordt toegevoegd. De browser accepteert inline scripts met de bijpassende nonce en weigert al het andere.
Hoe Rails Content Security Policy Nonces Werken met Turbo
Rails voegt de nonce-attribuut automatisch toe aan inline scripts die door zijn eigen helpers worden gegenereerd. De helpers javascript_tag, csrf_meta_tags en javascript_importmap_tags krijgen de nonce allemaal automatisch als nonces zijn ingeschakeld.
In je layout:
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<head>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", nonce: content_security_policy_nonce %>
<%= javascript_importmap_tags %>
</head>
De csp_meta_tag-helper stelt de huidige nonce beschikbaar als <meta name="csp-nonce">. Turbo leest deze tag en gebruikt de noncewaarde wanneer het scriptelementen in de pagina injecteert tijdens navigatie — precies daarom breekt Turbo Drive stilzwijgend op de tweede paginanavigatie als je nonces inschakelt zonder deze metatag.
Controleer in development dat je Turbo-scripttag de nonce heeft:
<!-- Wat je in de paginabron zou moeten zien -->
<script type="module" nonce="abc123==" src="/assets/application.js"></script>
Geen nonce-attribuut betekent dat de nonce niet wordt geïnjecteerd. Controleer of content_security_policy_nonce_generator is ingesteld en of je javascript_importmap_tags of javascript_include_tag gebruikt in plaats van een kale <script>-tag in je layout.
Voor Turbo Frames en Turbo Streams is de nonce-aanpak de enige optie die betrouwbaar werkt. Het alternatief — 'unsafe-inline' in script-src — ondergraaft het doel van een CSP volledig. Doe dat niet.
Report-Only Modus: Audit Voordat Je Handhaaft
Schakel nooit CSP-handhaving in productie in zonder eerst een rapportagefase. De juiste volgorde:
- Deploy met
Content-Security-Policy-Report-Only, die schendingen naar je rapportage-endpoint stuurt - Draai een tot twee weken in report-only modus
- Los elke schendingscategorie op die in de rapporten verschijnt
- Schakel over naar
Content-Security-Policy-handhaving
Rails maakt report-only modus tot een wijziging van één regel:
config.content_security_policy_report_only = true
In report-only modus blokkeert de browser niets — hij stuurt een JSON-POST naar je report_uri wanneer hij inhoud tegenkomt die geblokkeerd zou zijn. De pagina blijft functioneren terwijl jij elk gat ontdekt.
Het schendingsrapportage-endpoint:
# config/routes.rb
post "/csp_violation_reports", to: "csp_reports#create"
# app/controllers/csp_reports_controller.rb
class CspReportsController < ApplicationController
skip_before_action :verify_authenticity_token
skip_before_action :authenticate_user!, raise: false
def create
body = request.body.read
report = JSON.parse(body).fetch("csp-report", {})
Rails.logger.warn(
event: "csp_violation",
blocked_uri: report["blocked-uri"],
violated_directive: report["violated-directive"],
document_uri: report["document-uri"],
source_file: report["source-file"],
line_number: report["line-number"]
)
head :no_content
rescue JSON::ParserError
head :no_content
end
end
Met gestructureerde logs die naar je observability-stack stromen — de OpenTelemetry Rails 8-gids behandelt de setup — groepeer schendingen op violated_directive en blocked_uri. Eén URI die duizend keer verschijnt betekent één beleidsgat om te dichten. Fix het, deploy opnieuw en zie het uit de rapporten verdwijnen.
Action Cable en WebSocket-bronnen
Rails content security policy vereist expliciete toestemming voor WebSocket-verbindingen via de connect-src-directive. Action Cable verbindt via WebSocket en connect-src bepaalt welke origins als doel voor die verbindingen zijn toegestaan.
Voor Solid Cable zonder Redis gaat de verbinding naar je eigen domein. Het meest betrouwbare patroon is dit dynamisch instellen per omgeving:
Rails.application.configure do
config.content_security_policy do |policy|
# ... andere directives ...
policy.connect_src do
if Rails.env.production?
[:self, :https, "wss://jouwapp.nl"]
else
[:self, "http://localhost:3000", "ws://localhost:3000"]
end
end
end
end
De blokvorm van directive-configuratie evalueert per request, waardoor je waarden per omgeving kunt wisselen zonder de hele policy te dupliceren over meerdere initializers. Gebruik het voor alles wat verschilt tussen development en productie — WebSocket-URL’s, CDN-origins, foutrapportage-endpoints.
Omgaan met Externe Bronnen: Lettertypen, Afbeeldingen en Analytics
Zodra je iets laadt van een CDN, Google Fonts of een externe analyticsdienst, moet die origin expliciet worden vermeld in de bijpassende directive.
Een realistisch productie-CSP voor een Rails-app die Cloudfront gebruikt voor assets, Google Fonts voor typografie en Sentry voor foutbewaking:
config.content_security_policy do |policy|
policy.default_src :self, :https
policy.font_src :self, :https, "https://fonts.gstatic.com"
policy.img_src :self, :https, :data, :blob,
"https://jouwapp.s3.amazonaws.com",
"https://*.cloudfront.net"
policy.object_src :none
policy.script_src :self, :https
policy.style_src :self, :https, "https://fonts.googleapis.com"
policy.connect_src :self, :https,
"wss://jouwapp.nl",
"https://o123456.ingest.sentry.io"
policy.frame_src :none
policy.report_uri "https://o123456.ingest.sentry.io/api/123456/security/?sentry_key=..."
end
Let op policy.frame_src :none. Tenzij je app iframes insluit of door derden wordt ingesloten, is dit gratis beveiliging. Evenzo schakelt policy.object_src :none Flash en andere plugin-inhoud uit — er is geen geldige reden om dat toe te staan in een moderne Rails-app.
Sentry’s beveiligingseindpunt werkt ook als CSP-rapportagedoel, wat handig is: schendingen stromen naar hetzelfde dashboard als je uitzonderingen. Raadpleeg Sentry’s documentatie voor de exacte URL voor jouw organisatie en project.
Je Rails Content Security Policy Testen
CSP testen vereist dat je het inschakelt, wat dingen kapotmaakt in development totdat de policy klopt. Het patroon dat ik gebruik:
# config/environments/development.rb
Rails.application.configure do
config.content_security_policy_report_only = true
end
# config/environments/production.rb
Rails.application.configure do
config.content_security_policy_report_only = false
end
Met dit op zijn plek draait development in report-only modus. Open de browserconsole terwijl je de app normaal gebruikt — Chrome en Firefox tonen CSP-schendingmeldingen met duidelijke details over welke directive werd geschonden en welke resource werd geblokkeerd.
Een eenvoudige request-spec die controleert of de policy aanwezig en correct is:
# spec/requests/csp_spec.rb
RSpec.describe "Content Security Policy", type: :request do
before { get root_path }
it "setzt een CSP-header" do
expect(response.headers).to have_key("Content-Security-Policy-Report-Only")
.or have_key("Content-Security-Policy")
end
it "verbiedt object-src" do
header = response.headers["Content-Security-Policy"] ||
response.headers["Content-Security-Policy-Report-Only"]
expect(header).to include("object-src 'none'")
end
it "bevat een nonce in de script-src-directive" do
header = response.headers["Content-Security-Policy"] ||
response.headers["Content-Security-Policy-Report-Only"]
expect(header).to match(/script-src.*'nonce-[A-Za-z0-9+\/]+=*'/)
end
end
Deze drie assertions — header aanwezig, object-src vergrendeld, nonce aanwezig — vangen de meest voorkomende CSP-misconfiguraties op voordat ze productie bereiken.
Deployen Zonder Productie Te Breken
De volgorde die betrouwbaar werkt:
- Voeg
config/initializers/content_security_policy.rbtoe met je policy enreport_only = true - Voeg het schendingsrapportage-endpoint toe aan routes en de controller
- Deploy naar productie. Laat schendingsrapporten een tot twee weken binnenkomen.
- Los elke schendingscategorie in de rapporten op. Veelvoorkomende boosdoeners: inline
onclick-handlers in oude HTML, externe scripts dieeval()aanroepen, resources die worden geladen van vergeten domeinen. - Wanneer het schendingsvolume tot nul of bijna nul daalt, stel
report_only = falsein en deploy opnieuw. - Houd
report_uriactief ook na het omschakelen naar handhaving. Schendingen in handhavingsmodus zijn echte geblokkeerde inhoud — je wilt daar onmiddellijk van op de hoogte zijn.
De Rack Attack rate-limitinggids is het lezen waard als je schendingsendpoint door geautomatiseerde scanners wordt gebombardeerd — het is een publiek POST-endpoint dat ontdekt zal worden.
Veelgestelde Vragen
Werkt Rails content security policy met Importmap en Propshaft?
Ja. javascript_importmap_tags voegt de nonce automatisch toe aan de gegenereerde <script type="importmap">- en <script type="module-shim">-tags. Propshaft levert assets van /assets/ — script-src 'self' dekt ze zonder extra CDN-bron. Geen extra configuratie nodig als je assets zelf host.
Hoe sta ik inline stijlen toe die Turbo en Stimulus vereisen?
Turbo stelt soms inline stijlen in voor de voortgangsbalk en enkele animaties. Voeg style-src-attr 'unsafe-inline' toe als smal uitzonderingsgeval — stijlen kunnen geen gegevens exfiltreren zoals scripts dat kunnen, dus unsafe-inline voor stijlen is een pragmatisch compromis. Je kunt ook style-src toevoegen aan content_security_policy_nonce_directives en de nonce-helper expliciet gebruiken voor inline stijlen die je zelf beheert.
Waarom breekt mijn CSP na een Turbo-upgrade?
Turbo-hoofdversies wijzigen soms hoe ze scripts in de DOM injecteren. Schakel na een Turbo-upgrade een paar dagen over naar report-only modus en controleer schendingsrapporten voordat je handhaving opnieuw inschakelt. Baanbrekende CSP-wijzigingen in Turbo zijn zeldzaam maar gedocumenteerd in de CHANGELOG — zoek naar “nonce” en “inline” in de releasenotities van elke versie.
Kan ik nonces gebruiken met een CDN zoals Cloudflare?
Nonces vereisen een unieke waarde per request, wat betekent dat antwoorden met nonces niet vanuit de edge-cache kunnen worden geserveerd. Als je HTML achter een CDN plaatst, configureer dan cache-bypass voor HTML-antwoorden (en cache alleen assets), of accepteer dat gecachede pagina’s geen nonces kunnen gebruiken. De meeste Rails-apps serveren HTML vanaf de origin en gebruiken de CDN alleen voor statische assets — in die opzet werken nonces zonder speciale configuratie en cachet de CDN alles wat het zou moeten.
Wil je de beveiliging van je Rails-app aanscherpen en weet je niet waar je moet beginnen? TTB Software heeft productie-Rails-applicaties beveiligd voor klanten in fintech, zorg en SaaS — negentien jaar lang. We weten welke securityheaders er echt toe doen en welke alleen onderhoudslast opleveren zonder betekenisvolle bescherming.
Related Articles
Rails Event Sourcing: Append-Only Domain Events, Projecties en CQRS in Productie
Rails event sourcing: bouw append-only domain event logs, schrijf projecties en implementeer CQRS-patronen in product...
Rails Zeitwerk Autoloading: NameErrors oplossen, Eager Loading en de Classic Loader Migratie
Rails Zeitwerk autoloading uitgelegd: fix NameErrors, begrijp eager loading valkuilen, migreer van Classic loader, en...
Rails API Versiebeheer: URL Namespaces, Header Routing en Nette Deprecatie
Rails API versiebeheer goed aanpakken: URL namespaces, Accept header routing, controller overerving en Sunset headers...