Skip to content

Commit

Permalink
Allow passing csp_nonce_assign_key into router
Browse files Browse the repository at this point in the history
  • Loading branch information
hoyon committed Apr 5, 2024
1 parent 30120c9 commit 8f20daa
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 28 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,26 @@ defmodule MyPhoenixAppWeb.Router do
end
```

### Content Security Policy

Content security policy nonces can be passed into the router to allow usage of strict Content Security Policies throughout an application.

This can be achieved by passing in a `csp_nonce_assign_key` to the `FunWithFlags.UI.Router` forward. Values for the nonces should be set in the Conn assigns before reaching this router.

This an either be a single nonce value, or separate values for script and style tags.

For example:

``` elixir
forward "/", FunWithFlags.UI.Router, namespace: "feature-flags", csp_nonce_assign_key: :my_csp_nonce
```

Or:

``` elixir
forward "/", FunWithFlags.UI.Router, namespace: "feature-flags", csp_nonce_assign_key: %{style: :my_style_nonce, script: :my_script_nonce}
```

## Caveats

While the base `fun_with_flags` library is quite relaxed in terms of valid flag names, group names and actor identifers, this web dashboard extension applies some more restrictive rules.
Expand Down
15 changes: 14 additions & 1 deletion lib/fun_with_flags/ui/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ defmodule FunWithFlags.UI.Router do

@doc false
def call(conn, opts) do
conn = extract_namespace(conn, opts)
conn =
conn
|> extract_namespace(opts)
|> extract_csp_nonce_key(opts)
super(conn, opts)
end

Expand Down Expand Up @@ -286,6 +289,16 @@ defmodule FunWithFlags.UI.Router do
Plug.Conn.assign(conn, :namespace, "/" <> ns)
end

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

Plug.Conn.put_private(conn, :csp_nonce_assign_key, csp_nonce_assign_key)
end

defp assign_csrf_token(conn, _opts) do
csrf_token = Plug.CSRFProtection.get_csrf_token()
Expand Down
5 changes: 5 additions & 0 deletions lib/fun_with_flags/ui/templates.ex
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,9 @@ defmodule FunWithFlags.UI.Templates do
|> to_string()
|> URI.encode()
end

def csp_nonce(conn, type) do
csp_nonce_assign_key = conn.private.csp_nonce_assign_key[type]
conn.assigns[csp_nonce_assign_key]
end
end
4 changes: 2 additions & 2 deletions lib/fun_with_flags/ui/templates/_head.html.eex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
<meta charset="utf-8">
<title>FunWithFlags - <%= @title %></title>

<link rel="stylesheet" href="<%= path(@conn, "/assets/bootstrap.min.css") %>">
<link rel="stylesheet" href="<%= path(@conn, "/assets/style.css") %>">
<link nonce="<%= csp_nonce(@conn, :style) %>" rel="stylesheet" href="<%= path(@conn, "/assets/bootstrap.min.css") %>">
<link nonce="<%= csp_nonce(@conn, :style) %>" rel="stylesheet" href="<%= path(@conn, "/assets/style.css") %>">
</head>
2 changes: 1 addition & 1 deletion lib/fun_with_flags/ui/templates/details.html.eex
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,6 @@
</div>
</div>
</div>
<script type="text/javascript" src="<%= path(@conn, "/assets/details.js") %>"></script>
<script nonce="<%= csp_nonce(@conn, :script) %>" type="text/javascript" src="<%= path(@conn, "/assets/details.js") %>"></script>
</body>
</html>
99 changes: 75 additions & 24 deletions test/fun_with_flags/ui/templates_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@ defmodule FunWithFlags.UI.TemplatesTest do
import FunWithFlags.UI.TestUtils

setup_all do
on_exit(__MODULE__, fn() -> clear_redis_test_db() end)
on_exit(__MODULE__, fn -> clear_redis_test_db() end)
:ok
end

setup do
conn = Plug.Conn.assign(%Plug.Conn{}, :namespace, "/pear")
conn = Plug.Conn.assign(conn, :csrf_token, Plug.CSRFProtection.get_csrf_token())
conn =
%Plug.Conn{}
|> Plug.Conn.assign(:namespace, "/pear")
|> Plug.Conn.put_private(:csp_nonce_assign_key, %{style: :style_key, script: :script_key})
|> Plug.Conn.assign(:csrf_token, Plug.CSRFProtection.get_csrf_token())

{:ok, conn: conn}
end


describe "_head()" do
test "it renders", %{conn: conn} do
out = Templates._head(conn: conn, title: "Coconut")
Expand All @@ -31,13 +34,13 @@ defmodule FunWithFlags.UI.TemplatesTest do
end
end


describe "index()" do
setup do
flags = [
%Flag{name: :pineapple, gates: [Gate.new(:boolean, true)]},
%Flag{name: :papaya, gates: [Gate.new(:boolean, false)]},
%Flag{name: :papaya, gates: [Gate.new(:boolean, false)]}
]

{:ok, flags: flags}
end

Expand All @@ -55,7 +58,6 @@ defmodule FunWithFlags.UI.TemplatesTest do
end
end


describe "details()" do
setup do
flag = %Flag{name: :avocado, gates: []}
Expand All @@ -77,38 +79,65 @@ defmodule FunWithFlags.UI.TemplatesTest do
test "it includes the CSRF token", %{conn: conn, flag: flag} do
csrf_token = Plug.CSRFProtection.get_csrf_token()
out = Templates.details(conn: conn, flag: flag)
assert String.contains?(out, ~s{<input type="hidden" name="_csrf_token" value="#{csrf_token}">})

assert String.contains?(
out,
~s{<input type="hidden" name="_csrf_token" value="#{csrf_token}">}
)
end

test "it includes the global toggle, the new actor and new group forms, and the global delete form", %{conn: conn, flag: flag} do
test "it includes the global toggle, the new actor and new group forms, and the global delete form",
%{conn: conn, flag: flag} do
out = Templates.details(conn: conn, flag: flag)
assert String.contains?(out, ~s{<form id="fwf-global-toggle-form" action="/pear/flags/avocado/boolean" method="post"})
assert String.contains?(out, ~s{<form id="fwf-new-actor-form" action="/pear/flags/avocado/actors" method="post"})
assert String.contains?(out, ~s{<form id="fwf-new-group-form" action="/pear/flags/avocado/groups" method="post"})
assert String.contains?(out, ~s{<form id="fwf-delete-flag-form" action="/pear/flags/avocado" method="post">})

assert String.contains?(
out,
~s{<form id="fwf-global-toggle-form" action="/pear/flags/avocado/boolean" method="post"}
)

assert String.contains?(
out,
~s{<form id="fwf-new-actor-form" action="/pear/flags/avocado/actors" method="post"}
)

assert String.contains?(
out,
~s{<form id="fwf-new-group-form" action="/pear/flags/avocado/groups" method="post"}
)

assert String.contains?(
out,
~s{<form id="fwf-delete-flag-form" action="/pear/flags/avocado" method="post">}
)
end

test "with no boolean gate, it includes both the enabled and disable boolean buttons", %{conn: conn, flag: flag} do
test "with no boolean gate, it includes both the enabled and disable boolean buttons", %{
conn: conn,
flag: flag
} do
out = Templates.details(conn: conn, flag: flag)
assert String.contains?(out, ~s{<button id="enable-boolean-btn" type="submit"})
assert String.contains?(out, ~s{<button id="disable-boolean-btn" type="submit"})
end

test "with an enabled boolean gate, it includes both the disable and clear boolean buttons", %{conn: conn, flag: flag} do
test "with an enabled boolean gate, it includes both the disable and clear boolean buttons",
%{conn: conn, flag: flag} do
f = %Flag{flag | gates: [Gate.new(:boolean, true)]}
out = Templates.details(conn: conn, flag: f)
assert String.contains?(out, ~s{<button id="disable-boolean-btn" type="submit"})
assert String.contains?(out, ~s{<button id="clear-boolean-btn" type="submit"})
end

test "with a disabled boolean gate, it includes both the enable and clear boolean buttons", %{conn: conn, flag: flag} do
test "with a disabled boolean gate, it includes both the enable and clear boolean buttons", %{
conn: conn,
flag: flag
} do
f = %Flag{flag | gates: [Gate.new(:boolean, false)]}
out = Templates.details(conn: conn, flag: f)
assert String.contains?(out, ~s{<button id="enable-boolean-btn" type="submit"})
assert String.contains?(out, ~s{<button id="clear-boolean-btn" type="submit"})
end


test "with no gates it reports the lists as empty", %{conn: conn, flag: flag} do
group_gate = %Gate{type: :group, for: :rocks, enabled: true}
actor_gate = %Gate{type: :actor, for: "moss:123", enabled: true}
Expand Down Expand Up @@ -139,28 +168,46 @@ defmodule FunWithFlags.UI.TemplatesTest do
out = Templates.details(conn: conn, flag: flag)

assert String.contains?(out, ~s{<div id="actor_moss:123"})
assert String.contains?(out, ~s{<form action="/pear/flags/avocado/actors/moss:123" method="post"})

assert String.contains?(
out,
~s{<form action="/pear/flags/avocado/actors/moss:123" method="post"}
)

assert String.contains?(out, ~s{<div id="group_rocks"})
assert String.contains?(out, ~s{<form action="/pear/flags/avocado/groups/rocks" method="post"})

assert String.contains?(
out,
~s{<form action="/pear/flags/avocado/groups/rocks" method="post"}
)
end

test "with actors and groups it contains their rows with escaped HTML and URLs", %{conn: conn, flag: flag} do
test "with actors and groups it contains their rows with escaped HTML and URLs", %{
conn: conn,
flag: flag
} do
group_gate = %Gate{type: :group, for: :rocks, enabled: true}
actor_gate = %Gate{type: :actor, for: "moss:<h1>123</h1>", enabled: true}
flag = %Flag{flag | gates: [actor_gate, group_gate]}

out = Templates.details(conn: conn, flag: flag)

assert String.contains?(out, ~s{<div id="actor_moss:&lt;h1&gt;123&lt;/h1&gt;"})
assert String.contains?(out, ~s{<form action="/pear/flags/avocado/actors/moss:%3Ch1%3E123%3C/h1%3E" method="post"})

assert String.contains?(
out,
~s{<form action="/pear/flags/avocado/actors/moss:%3Ch1%3E123%3C/h1%3E" method="post"}
)

assert String.contains?(out, ~s{<div id="group_rocks"})
assert String.contains?(out, ~s{<form action="/pear/flags/avocado/groups/rocks" method="post"})

assert String.contains?(
out,
~s{<form action="/pear/flags/avocado/groups/rocks" method="post"}
)
end
end


describe "new()" do
test "it renders", %{conn: conn} do
out = Templates.new(conn: conn)
Expand All @@ -170,7 +217,11 @@ defmodule FunWithFlags.UI.TemplatesTest do
test "it includes the right content", %{conn: conn} do
out = Templates.new(conn: conn)
assert String.contains?(out, "<title>FunWithFlags - New Flag</title>")
assert String.contains?(out, ~s{<form id="new-flag-form" action="/pear/flags" method="post">})

assert String.contains?(
out,
~s{<form id="new-flag-form" action="/pear/flags" method="post">}
)
end
end

Expand Down

0 comments on commit 8f20daa

Please sign in to comment.