Published on

Content Security Policy header with Phoenix LiveView

If you use sobelow you will get a Missing Content-Security-Policy error with a default Phoenix setup. Managing Content Security Policy (CSP) can be tedious, so you may just want to opt for a catch-all 'unsafe-inline' approach to bypass it all. We should do better than that.

In this blog post, I’ll build a module with helpers to integrate CSP handling into Phoenix and Phoenix LiveView. I think the code is too minimal to be a dependency, but some of it could maybe be added upstream in the put_secure_browser_headers plug in Phoenix.

Writing our first CSP header

CSP headers consist of directives with sources. The recommendation for a default policy is default-src 'self';. This mitigates most typical cross-site scripting (XSS) attacks. 'self' means that only resources of the same scheme, host, and port, as the page itself can be executed.

With Phoenix we have a bunch of data: blobs from the hero icons. These can’t be loaded with the above policy so we should add img-src: 'self' data:; to our policy. To add a second source, like a CDN, we just add it to the sources list: default-src 'self' https://cdn.example.com;.

This is straightforward, but now comes the tricky part. What if we have inline styles or scripts?

If we can’t move it out of the DOM we should use CSP hashing. The tag will only be executed if the hashed value of the content matches the CSP source hash. As an example, we can set up the sources list with script-src 'sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc='; for the following inline script to execute:

<script>doSomething();</script>

What if we have styles or scripts with dynamic content? If we are only dealing with REST this would be trivial as we can just produce the hash on each request, but we got our LiveView socket pushing diffs while the CSP header has already been sent. If possible we could calculate all the possible hash values and add them to the CSP source list, but in my case, there were way too many variants.

We shouldn’t opt for the obviously unsafe 'unsafe-inline'. Instead, we will use a nonce.

On the initial request, we will generate the nonce and then ensure we use the same nonce throughout the LiveView session.

An inline style example

To use inline styles with nonces we must use <style> tags. Elements with style attributes only support CSP hashes. So we must move any style attributes out to <style> tags with a nonce attribute:

<span id="progress"></span>

<style nonce={get_csp_nonce()}>
  #progress {
    width: <%= @progress %>%;
  }
</style>

CSP helpers

Now let us set up our CSP module. It will look similar to the CSRF plug. The important points here are:

  • A unique nonce is used on each REST response
  • The nonce is generated using a cryptographically secure random number generator
  • The nonce has at least 128 bits of entropy
  • The same nonce is used across the initial request process
  • The same nonce is used across the LiveView Socket
defmodule MyAppWeb.CSP do
  @moduledoc """
  Module that includes Plug and LiveView helpers to handle Content Security
  Policy header.

  For inline `<style>` and `<script>` tags, nonce should be used. When the REST
  request is processed a nonce is added to the process dictionary. This ensures
  the nonce stays the same throughout the call, as the nonce in the tags must
  match the nonce in the header.

  To allow for inline `<style>` and/or `<script>` tag you must set a `'nonce'`
  source.

  ## Set up

  To set up MyAppWeb.CSP in your app you must:

  ### 1) Configure `lib/my_app_web.ex`

  Ensure you import the helpers in `MyAppWeb`.

    def router do
      quote do
        use Phoenix.Router, helpers: false

        # Import common connection and controller functions to use in pipelines
        import Plug.Conn
        import Phoenix.Controller
        import Phoenix.LiveView.Router

        import MyAppWeb.CSP, only: [put_content_security_policy: 2]
      end
    end

    # ...

    def html do
      quote do
        use Phoenix.Component

        import MyAppWeb.CldrHelpers

        # Import convenience functions from controllers
        import Phoenix.Controller,
          only: [get_csrf_token: 0, view_module: 1, view_template: 1]

        import MyAppWeb.CSP,
          only: [get_csp_nonce: 0]

        # Include general helpers for rendering HTML
        unquote(html_helpers())
      end
    end

    # ...

    def live_view do
      quote do
        use Phoenix.LiveView,
          layout: {MyAppWeb.Layouts, :app}

        on_mount MyAppWeb.CSP

        unquote(html_helpers())
      end
    end

  ### 2) Add nonce metatag to the HTML document

  Add the following metatag head to
  `lib/my_app_web/components/layouts/root.html.heex`.

    <meta name="csp-nonce" content={get_csp_nonce()} />

  ### 3) Pass the CSP nonce to the LiveView socket

  Ensure you pass on the CSP nonce to the LiveView socket in
  `assets/js/app.js`.

    let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
    let cspNonce = document.querySelector("meta[name='csp-nonce']").getAttribute("content")
    let liveSocket = new LiveSocket("/live", Socket, {
      longPollFallbackMs: 2500,
      params: { _csrf_token: csrfToken, _csp_nonce: cspNonce }
    })

  ## Usage

  If you got inline `<style>` or script tags you must set the nonce attribute:

      <style nonce={get_csp_nonce()}>
        // ...
      </style>
  """
  require Logger

  import Plug.Conn

  @doc """
  Sets a content security policy header.

  By default the policy is `default-src 'self'`. `'nonce'` source will be
  expanded with an auto-generated nonce that is persisted in the process
  dictionary.

  The options can be a function or a keyword list. Sources can be a binary
  or list of binaries. Duplicate directives will be merged together.

  ## Example

    plug :put_content_security_policy,
      img_src: "'self' data:`,
      style_src: "'self' 'nonce'"

    plug :put_content_security_policy,
      img_src: [
        "'self'",
        "data:"
      ]

    plug :put_content_security_policy, &MyAppWeb.CSPPolicy.opts/1
  """
  def put_content_security_policy(conn, fun) when is_function(fun, 1) do
    put_content_security_policy(conn, fun.(conn))
  end

  def put_content_security_policy(conn, opts) when is_list(opts) do
    csp =
      opts
      |> Keyword.has_key?(:default_src)
      |> case do
        false -> [default_src: "'self'"] ++ opts
        true -> opts
      end
      |> Enum.reduce([], fn{name, sources}, acc ->
        sources = List.wrap(sources)

        Keyword.update(acc, name, sources, & &1 ++ sources)
      end)
      |> Enum.reduce("", fn {name, sources}, acc ->
        name = String.replace(to_string(name), "_", "-")

        sources =
          sources
          |> Enum.uniq()
          |> Enum.join(" ")
          |> String.replace("'nonce'", "'nonce-#{get_csp_nonce()}'")

        "#{acc}#{name} #{sources};"
      end)

    put_resp_header(conn, "content-security-policy", csp)
  end

  @doc """
  Gets the CSP nonce.

  Generates a nonce and stores it in the process dictionary if one does not exist.
  """
  def get_csp_nonce do
    if nonce = Process.get(:plug_csp_nonce) do
      nonce
    else
      nonce = csp_nonce()
      Process.put(:plug_csp_nonce, nonce)
      nonce
    end
  end

  defp csp_nonce do
    24
    |> :crypto.strong_rand_bytes()
    |> Base.encode64(padding: false)
  end

  @doc """
  Loads the CSP nonce into the LiveView process.
  """
  def on_mount(:default, _params, _session, %{private: %{connect_params: %{"_csp_nonce" => nonce}}} = socket) do
    Process.put(:plug_csp_nonce, nonce)

    {:cont, socket}
  end

  def on_mount(:default, _params, _session, socket) do
    unless Process.get(:plug_csp_nonce) do
      Logger.debug("""
      LiveView session was misconfigured.

      1) Ensure the `put_content_security_policy` plug is in your router pipeline:

          plug :put_content_security_policy

      2) Define the CSRF meta tag inside the `<head>` tag in your layout:

          <meta name="csp-nonce" content={MyAppWeb.CSP.get_csp_nonce()} />

      3) Pass it forward in your app.js:

          let csrfToken = document.querySelector("meta[name='csp-nonce']").getAttribute("content");
          let liveSocket = new LiveSocket("/live", Socket, {params: {_csp_nonce: cspNonce}});
      """)
    end

    {:cont, socket}
  end
end

The module documents how to install the CSP helpers in your Phoenix app. This is how I’m using the plug helper in my router pipeline:

pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_live_flash
  plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
  plug :protect_from_forgery
  plug :put_secure_browser_headers

  plug :put_content_security_policy,
    img_src: "'self' data:",
    style_src: "'self' 'nonce'"

  plug :load_current_user
end

Unfortunately, sobelow can’t skip plugs with inline skip comment so we must run mix sobelow --mark-skip-all to generate a .sobelow-skips file to skip the error in subsequent runs.

If you need to dynamically set CSP headers you can pass in a function:

plug :put_content_security_policy, &MyAppWeb.CSPPolicy.policy/1
defmodule MyAppWeb.CSPPolicy do
  @moduledoc false

  def policy(_conn) do
    if dsn = Application.get_env(:sentry, :dsn) do
      dsn = URI.parse(dsn)

      [
        script_src: [
          "https://js.sentry-cdn.com/",
          "https://browser.sentry-cdn.com/",
          "blob:"
        ],
        connect_src: [
          URI.to_string(%{dsn | userinfo: nil, path: "/api#{dsn.path}/"})
        ]
      ]
    else
      []
    end
  end
end

Considerations

There is a potential security risk using nonce here; an attacker may find a way to inject payload through the LiveView socket with the DOM manipulation. That could be in unescaped user content pushed to a <style> or <script> tag. The hash eliminates these risks, and you should opt for no inline content if possible.

Tests for MyAppWeb.CSP.
defmodule MyAppWeb.CSPTest do
  use MyAppWeb.ConnCase

  alias MyAppWeb.CSP

  describe "put_content_security_policy/2" do
    test "sets default CSP header", %{conn: conn} do
      conn = CSP.put_content_security_policy(conn, [])

      assert get_resp_header(conn, "content-security-policy") == ["default-src 'self';"]
    end

    test "with options", %{conn: conn} do
      conn = CSP.put_content_security_policy(conn, img_src: "'self' data:")

      assert get_resp_header(conn, "content-security-policy") == ["default-src 'self';img-src 'self' data:;"]
    end

    test "with list of sources", %{conn: conn} do
      conn = CSP.put_content_security_policy(conn, default_src: ["'self'", "data:"])

      assert get_resp_header(conn, "content-security-policy") == ["default-src 'self' data:;"]
    end

    test "with duplicate directives", %{conn: conn} do
      conn = CSP.put_content_security_policy(conn, default_src: "'self'", default_src: "data:")

      assert get_resp_header(conn, "content-security-policy") == ["default-src 'self' data:;"]
    end

    test "with duplicate sources", %{conn: conn} do
      conn = CSP.put_content_security_policy(conn, default_src: ["'self'", "data:"], default_src: "data:")

      assert get_resp_header(conn, "content-security-policy") == ["default-src 'self' data:;"]
    end

    test "with nonce source", %{conn: conn} do
      conn = CSP.put_content_security_policy(conn, default_src: "'self' 'nonce'")

      assert ["default-src 'self' 'nonce-" <> _nonce] = get_resp_header(conn, "content-security-policy")
    end

    defp my_function(conn) do
      [
        default_src: conn.host
      ]
    end

    test "with function", %{conn: conn} do
      conn = CSP.put_content_security_policy(conn, &my_function/1)

      assert get_resp_header(conn, "content-security-policy") == ["default-src #{conn.host};"]
    end
  end

  describe "get_csp_nonce/0" do
    test "token has no padding" do
      refute CSP.get_csp_nonce() =~ "="
    end

    test "token is stored in process dictionary" do
      assert CSP.get_csp_nonce() == CSP.get_csp_nonce()

      token = CSP.get_csp_nonce()
      Process.delete(:plug_csp_nonce)
      assert token != CSP.get_csp_nonce()
    end
  end
end

Hi, I'm Dan Schultzer, I write in this blog, work a lot in Elixir, maintain several open source projects, and help companies streamline their development process