Skip to content

Security headers ​

bash
bin/rails g trek:install:security_headers

bin/rails g trek:install runs it. It sets up the headers that tell browsers what a page may do, so that a script injected into a page (an XSS) can do as little as possible:

HeaderSet byDefault
Content-Security-Policy-Report-Only, then Content-Security-Policyconfig/initializers/content_security_policy.rbsee below
Permissions-Policyconfig/initializers/permissions_policy.rbcamera, microphone, geolocation, payment, USB… off
Strict-Transport-Securityconfig.force_ssl in production, which trek:install:config turns on2 years, subdomains included
Referrer-PolicyRailsstrict-origin-when-cross-origin: other sites only see the origin
X-Content-Type-Options, X-Frame-OptionsRailsnosniff, SAMEORIGIN

The headers apply to every response of the app: the admin and the front office alike.

An app with its own content_security_policy.rb keeps it: the generator only replaces Rails' commented-out example, and says so otherwise. Compare yours with Trek's below.

Content Security Policy ​

DirectiveSourcesWhy
default-src'self'anything else comes from the app
script-src'self', the nonceno inline script, unless written with the nonce (javascript_tag nonce: true)
style-src'self', 'unsafe-inline'Trek's components and Backpack's set style attributes (colors, custom properties)
font-src'self', data:the admin's font, Inter, comes with Trek's npm package
img-src'self', data:, blob:, the upload hostsuploads come from S3_BASE_URL once stored, and from S3_ENDPOINT just after upload; blob: and data: for previews
media-src'self', blob:, the upload hoststhe same, without data:
connect-src'self'uploads go through the app's upload endpoint
report-uri/csp-reportssee Violation reports
object-src'none'no plugin
frame-ancestors'self'framed by its own pages only (Lookbook), against clickjacking
base-uri, form-action'self'

The nonce is the session's, so Turbo, which renders the next pages under the first one's policy, gives their inline scripts a nonce that policy knows. A visitor without a session gets a random one per page: prefer script files to inline scripts on pages Turbo visits before any session.

Report first, then enforce ​

The policy only reports violations at first (config.content_security_policy_report_only = true): a missed source would otherwise break pages. Browse the admin and the front office with the browser's console open, and watch the violations visitors' browsers report (below). Once none is left but noise, set content_security_policy_report_only to false.

Violation reports ​

Browsers send each violation to the policy's report-uri: /csp-reports, which Trek::CspReportsController answers (the generator adds post "csp-reports" to the routes). It reports each violation to AppSignal, when the app uses it, in a csp namespace: one incident per directive and blocked origin (e.g. script-src-elem https://cdn.example.net), tagged with the page and whether the policy was enforced, so a new violation is notified once and closed once fixed. To tell what was blocked, each report is also tagged with the browser's user agent and, when the browser gives them, where it happened (source: the file's URL, without its query, and the line) and the sample: the first 40 characters of an inline script, which the policy asks for with 'report-sample' (policy.script_src :self, :report_sample). Tags are cut to 255 characters, as AppSignal drops longer ones. It logs each one too ([CSP] script-src-elem blocked inline on /pages/team (report) at https://www.example.com/pages/team:12: "alert(1)"), which is all an app without AppSignal gets.

It keeps the reports about the app's own pages only, and drops what browser extensions load or inject, which makes most reports and says nothing about the app. It keeps the page's path, not its query, which may carry a token, and the blocked resource's origin, not its URL. Browsers send no CSRF token nor cookie: the endpoint needs neither, and answers 204.

Anyone can send reports, so a flood mustn't bury the real violations:

  • a report must be plausible: a directive CSP has, and a keyword (inline, eval, data…) or a host name blocked;
  • AppSignal gets each violation once an hour at most, and 20 different ones an hour at most: the others are only logged;
  • 60 reports a minute per IP address at most, and 600 for all of them together.

These counters are kept in Rails.cache: with a cache per process or per server (:memory_store, :file_store), each counts on its own, so the limits multiply. Use a shared store, such as Solid Cache or Redis, when the app runs more than one.

To send the reports elsewhere, e.g. to a service such as Report URI, set CSP_REPORT_URI. Mind that reports hold visitors' page URLs and IP addresses: under the GDPR, such a service processes personal data for the app.

Adding a source ​

Each third party the front office uses needs its hosts, in the directives it uses them for:

ruby
# Analytics
policy.script_src :self, "https://plausible.io"
policy.connect_src :self, "https://plausible.io"

# Embedded videos
policy.frame_src "https://www.youtube-nocookie.com"

# Google Fonts (commented out in the generated policy)
policy.style_src :self, :unsafe_inline, "https://fonts.googleapis.com"
policy.font_src :self, :data, "https://fonts.gstatic.com"

A directive written twice replaces the first: keep its sources in one call.

Fonts are better served by the app, as Trek does for the admin's with a Fontsource package: the policy needs no extra host, and visitors' IP addresses don't go to Google, which the GDPR would require a reason for.

A policy for one part of the app ​

A controller can change the policy for its own pages, e.g. to allow videos on the front office's pages only, and its subclasses inherit the change:

ruby
class PagesController < ApplicationController
  content_security_policy do |policy|
    policy.frame_src "https://www.youtube-nocookie.com"
  end
end

The same way, Admin::BaseController can tighten the admin's policy, and content_security_policy_report_only can keep one part reporting while the rest is enforced.

Permissions-Policy ​

Turns off the browser features the app doesn't use: accelerometer, camera, screen capture, geolocation, gyroscope, HID, magnetometer, microphone, MIDI, payment, serial, USB. Passkeys' WebAuthn stays on. To allow one for the app's own pages, e.g. a map locating the visitor, replace geolocation=() with geolocation=(self).

Rails' own config.permissions_policy still sends the former Feature-Policy header, which browsers are dropping: the initializer sets Permissions-Policy itself.

HSTS ​

force_ssl makes browsers use HTTPS only, for 2 years, on subdomains too (includeSubDomains). To be preloaded in browsers, so that even the first visit uses HTTPS, add the preload flag in config/environments/production.rb, then submit the domain on hstspreload.org. Every subdomain must serve HTTPS first: preloading is hard to undo.

ruby
config.ssl_options = { hsts: { subdomains: true, preload: true } }

Checking ​

Once deployed, Mozilla's HTTP Observatory grades the headers. The report-only policy doesn't count: enforce it to reach an A.

Released under the MIT License.