Content security policies provide an excellent service by reducing a website’s attack surface, but they are (nearly) unforgivably evil to debug. I will forgo the telling of the rage-soaked journey I walked, trying to figure any of this stuff out.

You could write put_resp_header("content-security-policy", @policy), but don’t do it. It is the path of confusion. What I settled upon is a header I knew nothing about.

content-security-policy-report-only

Is there more than one kind of content security policy header? Yes! Why? I don’t know, and I’ve come too far to ask now. We are just gonna roll with it. This magical header will replace the useless errors in the console with a POST and send it to an endpoint with a JSON payload describing the error.

Is it a useful error? … Probably. It is frustrating to have to pull a browser error out of an API call you didn’t know you needed to implement, but we are beggars inside browser land, and we must take what is given when security requires obscurity.

The other major lesson I learned from this, for all the sins of the modern web, I have many plugins to try and keep the World Wide Web in check. Always test Content Security Policy header in a browser or profile that has no extensions. They, to my horror, can easily be the source of violations of your content security policies.

Now I will show a quick implementation of placing a Content Security Policy in Phoenix that, when in dev, implements the report endpoint for the report-only variant. My test project is called Kit.

Starting first with configuration:

# config/config.exs
config :kit, :csp_header_kind, "content-security-policy"
# config/dev.exs
config :kit, :csp_header_kind, "content-security-policy-report-only"

Depending on what and how you test in relation to your Content Security Policy, you may have to change the above, but for me, it is sufficient to test only in dev. You may want to put this in production for a period of time to understand the kind of errors your users are experiencing.

From here I implement a module that is easily configurable for basic Content Security Policy values.

defmodule KitWeb.ContentSecurityPolicy do
  @moduledoc """
  Content Security Policy (CSP) configuration.
  """

  @csp_config Application.compile_env(:kit, :csp_headers, [])

  @default_values %{
    default_src: "'self'",
    font_src: "'self'",
    frame_src: "'self'",
    img_src: "'self'",
    script_src: "'self'",
    style_src: "'self'",
    connect_src: "'self'",
    object_src: "'none'",
    base_uri: "'none'",
    form_action: "'self'",
    frame_ancestors: "'self'"
  }

  # Build directives by combining defaults with config
  @default_src "default-src #{Keyword.get(@csp_config, :default_src, @default_values.default_src)};"
  @font_src "font-src #{Keyword.get(@csp_config, :font_src, @default_values.font_src)};"
  @frame_src "frame-src #{Keyword.get(@csp_config, :frame_src, @default_values.frame_src)};"
  @img_src "img-src #{Keyword.get(@csp_config, :img_src, @default_values.img_src)};"
  @script_src "script-src #{Keyword.get(@csp_config, :script_src, @default_values.script_src)};"
  @style_src "style-src #{Keyword.get(@csp_config, :style_src, @default_values.style_src)};"
  @connect_src "connect-src #{Keyword.get(@csp_config, :connect_src, @default_values.connect_src)};"
  @object_src "object-src #{Keyword.get(@csp_config, :object_src, @default_values.object_src)};"
  @base_uri "base-uri #{Keyword.get(@csp_config, :base_uri, @default_values.base_uri)};"
  @form_action "form-action #{Keyword.get(@csp_config, :form_action, @default_values.form_action)};"
  @frame_ancestors "frame-ancestors #{Keyword.get(@csp_config, :frame_ancestors, @default_values.frame_ancestors)};"

  # Precomputed policy
  @csp_header "#{@default_src} #{@font_src} #{@frame_src} #{@img_src} #{@script_src} #{@style_src} #{@connect_src} #{@object_src} #{@base_uri} #{@form_action} #{@frame_ancestors}"
  @rpt_header "#{@default_src} #{@font_src} #{@frame_src} #{@img_src} #{@script_src} #{@style_src} #{@connect_src} #{@object_src} #{@base_uri} #{@form_action} report-uri /debug/csp-reports;"

  def header(kind) do
    case kind do
      "content-security-policy-report-only" -> @rpt_header
      _ -> @csp_header
    end
  end
end

There are subtle differences between the header values for the different kinds, and you’d need to modify the report header with whatever endpoint you prefer. The frame ancestors’ value isn’t present in the report header as it is not valid.

Now, we must set up an endpoint on the router to receive the browser’s reports. I went with something like this:

if Application.compile_env(:kit, :dev_routes) do
  scope "/debug", KitWeb do
    pipe_through [:public, :api]
    post "/csp-reports", CSPReportController, :create
  end
end

The controller that handles this report can do many creative things with it, but I want the dumbest thing that works for my dev environment, which is pretty printing via the logger.

# lib/kit_web/controllers/csp_report_controller.ex
defmodule KitWeb.CSPReportController do
  use KitkWeb, :controller
  require Logger

  def create(conn, _params) do
    {:ok, body, _conn} = Plug.Conn.read_body(conn)

    case Jason.decode(body) do
      {:ok, report} ->
        # Pretty print the CSP report JSON and log it
        pretty_json = Jason.encode!(report, pretty: true)
        Logger.info("CSP Violation Report:\n#{pretty_json}")

      {:error, error} ->
        Logger.error("Failed to parse CSP report: #{inspect(error)}")
    end

    send_resp(conn, 204, "")
  end
end

Now we need a plug that serves up this header.

# lib/kit_web/plugs/security_policies.ex
defmodule KitWeb.Plug.SecurityPolicies do
  @moduledoc """
  A plug that sets Security Policy headers.
  """
  import Plug.Conn

  @csp_header_kind Application.compile_env(:kit, :csp_header_kind)

  def init(opts) do
    opts
  end

  def call(conn, _opts) do
    conn
    |> put_resp_header(
      @csp_header_kind,
      KitWeb.ContentSecurityPolicy.header(@csp_header_kind)
    )
    |> put_resp_header(
      "cross-origin-opener-policy",
      "same-origin"
    )
  end
end

And then wire this plug into the :browser pipeline in the router.

  pipeline :browser do
    ...
    plug KitWeb.Plug.SecurityPolicies
    plug :put_secure_browser_headers
    ...
  end

Now, you should have a much easier time debugging the content security policy header violations by examining the JSON reports in your logs.

Happy debugging 🕵️‍♂️.