Skip to content

Use CSP nonces for script and style tags #61

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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