Skip to content

Commit

Permalink
Use CSP nonces for script and style tags (#61)
Browse files Browse the repository at this point in the history
This pull request adds a new `:csp_nonce_assign_key` option to the
`error_tracker_dashboard/2` macro. If provided, the error tracker will
fetch the nonce from the given assign key and use it in the `<style>`
and `<script>` tags.

This allows using the ErrorTracker dashboard in environments with a
restricted content security policy without requiring the usage of
`unsafe-inline`, which should be avoided.

This implementation is based on the [Phoenix
LiveDashboard](https://hexdocs.pm/phoenix_live_dashboard/Phoenix.LiveDashboard.Router.html#live_dashboard/2)
one.

I've updated the `dev.exs` script to use CSP headers. If we remove the
new option we will see that the ErrorTracker dashboard doesn't have any
styles.

Closes #58
  • Loading branch information
crbelaus authored Aug 24, 2024
1 parent eba2338 commit cfb9e59
Show file tree
Hide file tree
Showing 4 changed files with 52 additions and 13 deletions.
16 changes: 15 additions & 1 deletion dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ defmodule ErrorTrackerDevWeb.Router do
get "/exit", ErrorTrackerDevWeb.PageController, :exit

scope "/dev" do
error_tracker_dashboard "/errors"
error_tracker_dashboard "/errors", csp_nonce_assign_key: :my_csp_nonce
end
end
end
Expand All @@ -142,10 +142,24 @@ defmodule ErrorTrackerDevWeb.Endpoint do
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug :maybe_exception
plug :set_csp
plug ErrorTrackerDevWeb.Router

def maybe_exception(%Plug.Conn{path_info: ["plug-exception"]}, _), do: raise("Plug exception")
def maybe_exception(conn, _), do: conn

defp set_csp(conn, _opts) do
nonce = 10 |> :crypto.strong_rand_bytes() |> Base.encode64()

policies = [
"script-src 'self' 'nonce-#{nonce}';",
"style-src 'self' 'nonce-#{nonce}';"
]

conn
|> Plug.Conn.assign(:my_csp_nonce, "#{nonce}")
|> Plug.Conn.put_resp_header("content-security-policy", Enum.join(policies, " "))
end
end

defmodule ErrorTrackerDev.Telemetry do
Expand Down
4 changes: 2 additions & 2 deletions lib/error_tracker/web/components/layouts/root.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@

<title><%= assigns[:page_title] || "🐛 ErrorTracker" %></title>

<style>
<style nonce={@csp_nonces[:style]}>
<%= raw get_content(:css) %>
</style>
<script>
<script nonce={@csp_nonces[:script]}>
<%= raw get_content(:js) %>
</script>
</head>
Expand Down
8 changes: 6 additions & 2 deletions lib/error_tracker/web/hooks/set_assigns.ex
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
defmodule ErrorTracker.Web.Hooks.SetAssigns do
@moduledoc false

def on_mount({:set_dashboard_path, path}, _params, _session, socket) do
{:cont, %{socket | private: Map.put(socket.private, :dashboard_path, path)}}
import Phoenix.Component, only: [assign: 2]

def on_mount({:set_dashboard_path, path}, _params, session, socket) do
socket = %{socket | private: Map.put(socket.private, :dashboard_path, path)}

{:cont, assign(socket, csp_nonces: session["csp_nonces"])}
end
end
37 changes: 29 additions & 8 deletions lib/error_tracker/web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@ defmodule ErrorTracker.Web.Router do
ErrorTracker UI integration into your application's router.
"""

alias ErrorTracker.Web.Hooks.SetAssigns

@doc """
Creates the routes needed to use the `ErrorTracker` web interface.
It requires a path in which you are going to serve the web interface.
## Security considerations
Errors may contain sensitive information so it is recommended to use the `on_mount`
option to provide a custom hook that implements authentication and authorization
for access control.
The dashboard inlines both the JS and CSS assets. This means that, if your
application has a Content Security Policy, you need to specify the
`csp_nonce_assign_key` option, which is explained below.
## Options
Expand All @@ -21,6 +23,10 @@ defmodule ErrorTracker.Web.Router do
* `as`: a session name to use for the dashboard LiveView session. By default
it uses `:error_tracker_dashboard`.
* `csp_nonce_assign_key`: an assign key to find the CSP nonce value used for assets.
Supports either `atom()` or a map of type
`%{optional(:img) => atom(), optional(:script) => atom(), optional(:style) => atom()}`
"""
defmacro error_tracker_dashboard(path, opts \\ []) do
quote bind_quoted: [path: path, opts: opts] do
Expand All @@ -45,17 +51,32 @@ defmodule ErrorTracker.Web.Router do
@doc false
def parse_options(opts, path) do
custom_on_mount = Keyword.get(opts, :on_mount, [])

on_mount =
[{ErrorTracker.Web.Hooks.SetAssigns, {:set_dashboard_path, path}}] ++ custom_on_mount

session_name = Keyword.get(opts, :as, :error_tracker_dashboard)

csp_nonce_assign_key =
case opts[:csp_nonce_assign_key] do
nil -> nil
key when is_atom(key) -> %{img: key, style: key, script: key}
keys when is_map(keys) -> Map.take(keys, [:img, :style, :script])
end

session_opts = [
on_mount: on_mount,
session: {__MODULE__, :__session__, [csp_nonce_assign_key]},
on_mount: [{SetAssigns, {:set_dashboard_path, path}}] ++ custom_on_mount,
root_layout: {ErrorTracker.Web.Layouts, :root}
]

{session_name, session_opts}
end

@doc false
def __session__(conn, csp_nonce_assign_key) do
%{
"csp_nonces" => %{
img: conn.assigns[csp_nonce_assign_key[:img]],
style: conn.assigns[csp_nonce_assign_key[:style]],
script: conn.assigns[csp_nonce_assign_key[:script]]
}
}
end
end

0 comments on commit cfb9e59

Please sign in to comment.