A strict Content-Security-Policy and the standard security headers.
plug Macaw.Plug.SecurityHeadersThe 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
| Option | Default | |
|---|---|---|
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
- Start with
preset: :compatible: the headers Phoenix sends, plus HSTS over https. Nothing a Phoenix page did stops working. - Try the closed policy without enforcing it:
csp_mode: :report_only(with the strict preset) sends it ascontent-security-policy-report-only, so the browser console lists what it would block. Addcsp: [report_uri: "/csp-reports"]to collect the reports. - Allow what the pages need by name: an inline script by its hash
(
put_script_hash/2), a directive withcsp:, a browser feature withpermissions:. Phoenix's live-reload iframe needs, in development only,csp: [frame_ancestors: "'self'"]andheaders: %{"x-frame-options" => "SAMEORIGIN"}. - Enforce: drop
csp_mode:(andpreset:).
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)
The policy string for directives (the defaults, or as init/1 merged
them) and script hashes, which are added to script-src.
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
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):
iex> Macaw.Plug.SecurityHeaders.permissions_policy(geolocation: :self, camera: :all)
"geolocation=(self), camera=*"An entry that is not {feature, allowlist} raises ArgumentError.
The policy string for directives (the defaults, or as init/1 merged
them) and script hashes, which are added to script-src.
@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.