We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
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