improvement: Add :csp_nonce_assign_key to ash_admin options (fix for /issues/91) (#92)

This commit is contained in:
Peter Hartman 2024-03-09 19:58:16 +00:00 committed by GitHub
parent bebfea66a4
commit 99b8daed7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 105 additions and 8 deletions

View file

@ -87,12 +87,18 @@ plug :put_secure_browser_headers, %{"content-security-policy" => "default-src 's
in your router, then all of the styles and JavaScript used to power AshAdmin will be blocked by your browser.
To avoid this, you can add the specific AshAdmin nonces to the `default-src` allowlist, ie.
To avoid this, you can add the default AshAdmin nonces to the `default-src` allowlist, ie.
```elixir
plug :put_secure_browser_headers, %{"content-security-policy" => "default-src 'nonce-ash_admin-Ed55GFnX' 'self'"}
```
alternatively you can supply your own nonces to the `ash_admin` route by setting a `:csp_nonce_assign_key` in the options list, ie.
```elixir
ash_admin "/admin", csp_nonce_assign_key: :csp_nonce_value
```
This will allow AshAdmin-generated inline CSS and JS blocks to execute normally.
## Configuration

View file

@ -25,24 +25,32 @@ defmodule AshAdmin.Layouts do
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
<meta name="csrf-token" content={get_csrf_token()} />
<title><%= assigns[:page_title] || "Ash Admin" %></title>
<style nonce="ash_admin-Ed55GFnX">
<style nonce={csp_nonce(@conn, :style)}>
<%= raw(render("app.css", %{})) %>
</style>
<link
nonce={csp_nonce(@conn, :style)}
href="https://cdnjs.cloudflare.com/ajax/libs/jsoneditor/9.5.1/jsoneditor.min.css"
rel="stylesheet"
type="text/css"
/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsoneditor/9.5.1/jsoneditor.min.js">
<script
nonce={csp_nonce(@conn, :script)}
src="https://cdnjs.cloudflare.com/ajax/libs/jsoneditor/9.5.1/jsoneditor.min.js"
>
</script>
<link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css" />
<script src="https://unpkg.com/easymde/dist/easymde.min.js">
<link
nonce={csp_nonce(@conn, :style)}
rel="stylesheet"
href="https://unpkg.com/easymde/dist/easymde.min.css"
/>
<script nonce={csp_nonce(@conn, :script)} src="https://unpkg.com/easymde/dist/easymde.min.js">
</script>
</head>
<body>
<%= @inner_content %>
</body>
<script nonce="ash_admin-Ed55GFnX">
<script nonce={csp_nonce(@conn, :script)}>
<%= raw(render("app.js", %{})) %>
</script>
</html>
@ -58,4 +66,14 @@ defmodule AshAdmin.Layouts do
def live_socket_path(conn) do
[Enum.map(conn.script_name, &["/" | &1]) | conn.private.live_socket_path]
end
defp csp_nonce(conn, type) when type in [:script, :style, :img] do
csp_nonce_value = conn.private.ash_admin_csp_nonce[type]
case csp_nonce_value do
key when is_atom(key) -> conn.assigns[csp_nonce_value]
key when is_bitstring(key) -> csp_nonce_value
_ -> raise("Unexpected type of :csp_nonce_assign_key")
end
end
end

View file

@ -43,6 +43,23 @@ defmodule AshAdmin.Router do
Defines an AshAdmin route.
It expects the `path` the admin dashboard will be mounted at
and a set of options.
## Options
* `:live_socket_path` - Optional override for the socket path. it must match
the `socket "/live", Phoenix.LiveView.Socket` in your endpoint. Defaults to `/live`.
* `:on_mount` - Optional list of hooks to attach to the mount lifecycle.
* `:session` - Optional extra session map or MFA tuple to be merged with the session.
* `:csp_nonce_assign_key` - Optional assign key to find the CSP nonce value used for assets
Supports either `atom()` or
`%{optional(:img) => atom(), optional(:script) => atom(), optional(:style) => atom()}`
Defaults to `ash_admin-Ed55GFnX` for backwards compatibility.
* `:live_session_name` - Optional atom to name the `live_session`. Defaults to `:ash_admin`.
## Examples
defmodule MyAppWeb.Router do
use Phoenix.Router
@ -55,6 +72,7 @@ defmodule AshAdmin.Router do
pipe_through [:browser]
ash_admin "/admin"
ash_admin "/csp/admin", live_session_name: :ash_admin_csp, csp_nonce_assign_key: :csp_nonce_value
end
end
"""
@ -63,7 +81,14 @@ defmodule AshAdmin.Router do
import Phoenix.LiveView.Router
live_socket_path = Keyword.get(opts, :live_socket_path, "/live")
live_session :ash_admin,
csp_nonce_assign_key =
case opts[:csp_nonce_assign_key] do
nil -> %{img: "ash_admin-Ed55GFnX", style: "ash_admin-Ed55GFnX", script: "ash_admin-Ed55GFnX"}
key when is_atom(key) -> %{img: key, style: key, script: key}
%{} = keys -> Map.take(keys, [:img, :style, :script])
end
live_session opts[:live_session_name] || :ash_admin,
on_mount: List.wrap(opts[:on_mount]),
session:
{AshAdmin.Router, :__session__, [%{"prefix" => path}, List.wrap(opts[:session])]},
@ -72,7 +97,10 @@ defmodule AshAdmin.Router do
"#{path}/*route",
AshAdmin.PageLive,
:page,
private: %{live_socket_path: live_socket_path}
private: %{
live_socket_path: live_socket_path,
ash_admin_csp_nonce: csp_nonce_assign_key
}
)
end
end

View file

@ -1,6 +1,7 @@
defmodule AshAdmin.Test.PageLiveTest do
use ExUnit.Case, async: false
import Plug.Conn
import Phoenix.ConnTest
import Phoenix.LiveViewTest
@endpoint AshAdmin.Test.Endpoint
@ -22,4 +23,40 @@ defmodule AshAdmin.Test.PageLiveTest do
assert html =~ "body"
assert html =~ "String"
end
test "embeds default csp nonces" do
html =
build_conn()
|> get("/api/admin")
|> html_response(200)
assert html =~ "ash_admin-Ed55GFnX"
assert html =~ ~s|<script nonce="ash_admin-Ed55GFnX"|
assert html =~ ~s|<style nonce="ash_admin-Ed55GFnX"|
end
test "embeds user selected csp nonces" do
html =
build_conn()
|> assign(:csp_nonce_value, "csp_nonce")
|> get("/api/csp/admin")
|> html_response(200)
assert html =~ ~s|<script nonce="csp_nonce"|
assert html =~ ~s|<style nonce="csp_nonce"|
assert html =~ ~s|<link nonce="csp_nonce"|
refute html =~ "ash_admin-Ed55GFnX"
html =
build_conn()
|> assign(:script_csp_nonce, "script_nonce")
|> assign(:style_csp_nonce, "style_nonce")
|> get("/api/csp-full/admin")
|> html_response(200)
assert html =~ ~s|<script nonce="script_nonce"|
assert html =~ ~s|<style nonce="style_nonce"|
assert html =~ ~s|<link nonce="style_nonce"|
refute html =~ "ash_admin-Ed55GFnX"
end
end

View file

@ -10,6 +10,14 @@ defmodule AshAdmin.Test.Router do
pipe_through(:browser)
import AshAdmin.Router
csp_full = %{
img: :img_csp_nonce,
style: :style_csp_nonce,
script: :script_csp_nonce
}
ash_admin("/admin")
ash_admin("/csp/admin", live_session_name: :ash_admin_csp, csp_nonce_assign_key: :csp_nonce_value)
ash_admin("/csp-full/admin", live_session_name: :ash_admin_csp_full, csp_nonce_assign_key: csp_full)
end
end