Macaw.Plug.SecurityHeaders (macaw_plug v0.2.0)

Copy Markdown View Source

A strict Content-Security-Policy and the standard security headers.

plug Macaw.Plug.SecurityHeaders

The default policy is closed: everything from the app's own origin, nothing inline, nothing evaluated, nothing framed:

default-src 'self'; script-src 'self'; style-src 'self';
img-src 'self' data:; font-src 'self'; connect-src 'self';
object-src 'none'; base-uri 'none'; form-action 'self';
frame-ancestors 'none'

A host changes a directive only by naming it, csp: [img_src: "'self'"], so every loosening is visible in the host's code.

A host's own inline script is allowed by its SHA-256 hash, never by 'unsafe-inline'. The policy is written just before the response is sent, so a controller that renders such a script adds its hash on the way:

conn |> Macaw.Plug.SecurityHeaders.put_script_hash(script_body)

Also set: x-content-type-options: nosniff, referrer-policy: strict-origin-when-cross-origin, x-frame-options: DENY (for browsers that predate frame-ancestors), cross-origin-opener-policy: same-origin, a permissions-policy that turns off camera, microphone, geolocation, payment and USB, and over https strict-transport-security: max-age=31536000. Each can be replaced with headers: %{"name" => value}, or removed with nil. This replaces Phoenix's put_secure_browser_headers.

Options

OptionDefault
preset::strict:compatible starts from Phoenix's put_secure_browser_headers set
csp:[]directives by name, [img_src: "'self' https://tiles.example"]
csp_mode::enforce:report_only reports violations and blocks nothing; :off sends no policy
permissions:[]Permissions-Policy features by name, [geolocation: :self]; nil removes one
headers:%{}any header by name; nil removes it
hsts:"max-age=31536000"over https; not a string turns it off

Every option applies on top of the preset, and an unknown option raises.

Permissions are allowlists: :none (()), :self ((self)), :all (*), or a list of :self and http(s):// origins: permissions: [camera: [:self, "https://meet.example"]] gives camera=(self "https://meet.example").

Migrating from put_secure_browser_headers

  1. Start with preset: :compatible: the headers Phoenix sends, plus HSTS over https. Nothing a Phoenix page did stops working.
  2. Try the closed policy without enforcing it: csp_mode: :report_only (with the strict preset) sends it as content-security-policy-report-only, so the browser console lists what it would block. Add csp: [report_uri: "/csp-reports"] to collect the reports.
  3. Allow what the pages need by name: an inline script by its hash (put_script_hash/2), a directive with csp:, a browser feature with permissions:. Phoenix's live-reload iframe needs, in development only, csp: [frame_ancestors: "'self'"] and headers: %{"x-frame-options" => "SAMEORIGIN"}.
  4. Enforce: drop csp_mode: (and preset:).

Summary

Functions

A script body's CSP hash source, 'sha256-…'.

The Permissions-Policy string for features and their allowlists (the defaults, or as init/1 merged them)

Allows an inline script on this response by its SHA-256 hash. Takes the script's body (the text between <script> and </script>), exactly.

Functions

hash(body)

@spec hash(String.t()) :: String.t()

A script body's CSP hash source, 'sha256-…'.

permissions_policy(entries \\ [camera: :none, microphone: :none, geolocation: :none, payment: :none, usb: :none])

@spec permissions_policy(keyword()) :: String.t()

The Permissions-Policy string for features and their allowlists (the defaults, or as init/1 merged them):

iex> Macaw.Plug.SecurityHeaders.permissions_policy(geolocation: :self, camera: :all)
"geolocation=(self), camera=*"

An entry that is not {feature, allowlist} raises ArgumentError.

policy(directives \\ [default_src: "'self'", script_src: "'self'", style_src: "'self'", img_src: "'self' data:", font_src: "'self'", connect_src: "'self'", object_src: "'none'", base_uri: "'none'", form_action: "'self'", frame_ancestors: "'none'"], hashes \\ [])

@spec policy(keyword(String.t()), [String.t()]) :: String.t()

The policy string for directives (the defaults, or as init/1 merged them) and script hashes, which are added to script-src.

put_script_hash(conn, body)

@spec put_script_hash(Plug.Conn.t(), String.t()) :: Plug.Conn.t()

Allows an inline script on this response by its SHA-256 hash. Takes the script's body (the text between <script> and </script>), exactly.