RUBY ON RAILS · 13 MIN READ ·

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.

Rails Content Security Policy: CSP-headers, Nonces en Turbo-compatibiliteit

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:

  1. Deploy met Content-Security-Policy-Report-Only, die schendingen naar je rapportage-endpoint stuurt
  2. Draai een tot twee weken in report-only modus
  3. Los elke schendingscategorie op die in de rapporten verschijnt
  4. 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:

  1. Voeg config/initializers/content_security_policy.rb toe met je policy en report_only = true
  2. Voeg het schendingsrapportage-endpoint toe aan routes en de controller
  3. Deploy naar productie. Laat schendingsrapporten een tot twee weken binnenkomen.
  4. Los elke schendingscategorie in de rapporten op. Veelvoorkomende boosdoeners: inline onclick-handlers in oude HTML, externe scripts die eval() aanroepen, resources die worden geladen van vergeten domeinen.
  5. Wanneer het schendingsvolume tot nul of bijna nul daalt, stel report_only = false in en deploy opnieuw.
  6. Houd report_uri actief 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.

#rails-content-security-policy #rails-csp-nonce-turbo #rails-csp-headers-configuration #rails-csp-stimulus-hotwire #rails-csp-violation-reporting #rails-security-headers

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software