MishkaCmsWeb.Runtime.Plugs.SecurityHeaders (Mishka CMS Core v0.0.1)

Copy Markdown View Source

The response headers that are the same whatever was asked for, and the Content-Security-Policy, which is not.

Two audiences, two policies

The admin and the sites it publishes are the same application on the same origin, so the policy cannot be chosen by pipeline — every browser route goes through :browser. It is chosen by path.

A PUBLIC page is nearly strict already: measured, a database-backed page carries one inline <script> (the theme bootstrap, which now takes a nonce) and no inline style at all.

The ADMIN cannot be, and the reason is the page builder. vendor/tailwind-browser.js is Tailwind compiled to run in the browser: the reader types px-7 into the inspector and the utility has to exist a frame later, which it achieves by creating a <style> element — precisely what style-src governs, from a library that offers no nonce. An editor that generates CSS at runtime and a strict style-src cannot both be had.

Report-only first

:report_only is the default because a policy is a guess until the browser has checked it against real pages. It sends Content-Security-Policy-Report-Only, which reports violations and blocks nothing. Move to :enforce once the reports are quiet:

config :mishka_cms, MishkaCmsWeb.Runtime.Plugs.SecurityHeaders, csp: :enforce   # or :report_only, or :off

x-frame-options was set to DENY, and the builder is an iframe

DENY forbids framing by ANY page, including one from the same origin — and the editor's canvas is a same-origin iframe of /admin/dashboard/runtime/builder-canvas/…. It used to work in development only by accident: tidewave (then a only: [:dev] dependency, plugged into the endpoint) deleted the header in a before_send callback so its own iframe survived, and took the editor's along with it. In production nothing deleted it. That dependency is gone, which would have returned the bug had this header still said DENY.

So: SAMEORIGIN, and frame-ancestors 'self' for browsers that read CSP instead — which is all of them, and which is why the header is kept only for the ones that do not.

Summary

Functions

Whether every entry of origins is an origin security.frame_origins may hold: https://host or https://host:port, lower case, nothing after it.

Whether a style ATTRIBUTE is permitted — the one CSP question an author can walk into.

The nonce for this request, for a template that has to mark an inline <script> as ours.

Functions

call(conn, opts)

frame_origins?(origins)

@spec frame_origins?(term()) :: boolean()

Whether every entry of origins is an origin security.frame_origins may hold: https://host or https://host:port, lower case, nothing after it.

init(opts)

inline_style_allowed?()

@spec inline_style_allowed?() :: boolean()

Whether a style ATTRIBUTE is permitted — the one CSP question an author can walk into.

A nonce vouches for a <style> ELEMENT and there is no equivalent for an attribute, so a policy can only permit every one or refuse every one. The page builder lets an author put style on their own content, which makes refusing them a decision about what this CMS offers rather than housekeeping: the attribute does not error, it is simply ignored, and the author is left looking at a colour that did not apply.

So the editor reads this back and says so beside the field. One switch drives the header and the warning together, because a warning that can disagree with the policy is worse than none:

config :mishka_cms, MishkaCmsWeb.Runtime.Plugs.SecurityHeaders, inline_style: :forbid   # or :allow

:allow is the default, and is what a CMS whose editor offers the attribute should keep.

nonce(assigns)

@spec nonce(Plug.Conn.t() | map() | nil) :: String.t() | nil

The nonce for this request, for a template that has to mark an inline <script> as ours.

Reads the assign the plug set. nil when the plug did not run — a template must degrade to no attribute rather than to the string "nil", which would match nothing and block the script.