Skip to content

Commit 8f20daa

Browse files
committed
Allow passing csp_nonce_assign_key into router
1 parent 30120c9 commit 8f20daa

File tree

6 files changed

+117
-28
lines changed

6 files changed

+117
-28
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,26 @@ defmodule MyPhoenixAppWeb.Router do
9797
end
9898
```
9999

100+
### Content Security Policy
101+
102+
Content security policy nonces can be passed into the router to allow usage of strict Content Security Policies throughout an application.
103+
104+
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.
105+
106+
This an either be a single nonce value, or separate values for script and style tags.
107+
108+
For example:
109+
110+
``` elixir
111+
forward "/", FunWithFlags.UI.Router, namespace: "feature-flags", csp_nonce_assign_key: :my_csp_nonce
112+
```
113+
114+
Or:
115+
116+
``` elixir
117+
forward "/", FunWithFlags.UI.Router, namespace: "feature-flags", csp_nonce_assign_key: %{style: :my_style_nonce, script: :my_script_nonce}
118+
```
119+
100120
## Caveats
101121

102122
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.

lib/fun_with_flags/ui/router.ex

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ defmodule FunWithFlags.UI.Router do
3030

3131
@doc false
3232
def call(conn, opts) do
33-
conn = extract_namespace(conn, opts)
33+
conn =
34+
conn
35+
|> extract_namespace(opts)
36+
|> extract_csp_nonce_key(opts)
3437
super(conn, opts)
3538
end
3639

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

292+
defp extract_csp_nonce_key(conn, opts) do
293+
csp_nonce_assign_key =
294+
case opts[:csp_nonce_assign_key] do
295+
nil -> nil
296+
key when is_atom(key) -> %{style: key, script: key}
297+
%{} = keys -> Map.take(keys, [:style, :script])
298+
end
299+
300+
Plug.Conn.put_private(conn, :csp_nonce_assign_key, csp_nonce_assign_key)
301+
end
289302

290303
defp assign_csrf_token(conn, _opts) do
291304
csrf_token = Plug.CSRFProtection.get_csrf_token()

lib/fun_with_flags/ui/templates.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,9 @@ defmodule FunWithFlags.UI.Templates do
8282
|> to_string()
8383
|> URI.encode()
8484
end
85+
86+
def csp_nonce(conn, type) do
87+
csp_nonce_assign_key = conn.private.csp_nonce_assign_key[type]
88+
conn.assigns[csp_nonce_assign_key]
89+
end
8590
end

lib/fun_with_flags/ui/templates/_head.html.eex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
<meta charset="utf-8">
33
<title>FunWithFlags - <%= @title %></title>
44

5-
<link rel="stylesheet" href="<%= path(@conn, "/assets/bootstrap.min.css") %>">
6-
<link rel="stylesheet" href="<%= path(@conn, "/assets/style.css") %>">
5+
<link nonce="<%= csp_nonce(@conn, :style) %>" rel="stylesheet" href="<%= path(@conn, "/assets/bootstrap.min.css") %>">
6+
<link nonce="<%= csp_nonce(@conn, :style) %>" rel="stylesheet" href="<%= path(@conn, "/assets/style.css") %>">
77
</head>

lib/fun_with_flags/ui/templates/details.html.eex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,6 @@
140140
</div>
141141
</div>
142142
</div>
143-
<script type="text/javascript" src="<%= path(@conn, "/assets/details.js") %>"></script>
143+
<script nonce="<%= csp_nonce(@conn, :script) %>" type="text/javascript" src="<%= path(@conn, "/assets/details.js") %>"></script>
144144
</body>
145145
</html>

test/fun_with_flags/ui/templates_test.exs

Lines changed: 75 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,20 @@ defmodule FunWithFlags.UI.TemplatesTest do
77
import FunWithFlags.UI.TestUtils
88

99
setup_all do
10-
on_exit(__MODULE__, fn() -> clear_redis_test_db() end)
10+
on_exit(__MODULE__, fn -> clear_redis_test_db() end)
1111
:ok
1212
end
1313

1414
setup do
15-
conn = Plug.Conn.assign(%Plug.Conn{}, :namespace, "/pear")
16-
conn = Plug.Conn.assign(conn, :csrf_token, Plug.CSRFProtection.get_csrf_token())
15+
conn =
16+
%Plug.Conn{}
17+
|> Plug.Conn.assign(:namespace, "/pear")
18+
|> Plug.Conn.put_private(:csp_nonce_assign_key, %{style: :style_key, script: :script_key})
19+
|> Plug.Conn.assign(:csrf_token, Plug.CSRFProtection.get_csrf_token())
20+
1721
{:ok, conn: conn}
1822
end
1923

20-
2124
describe "_head()" do
2225
test "it renders", %{conn: conn} do
2326
out = Templates._head(conn: conn, title: "Coconut")
@@ -31,13 +34,13 @@ defmodule FunWithFlags.UI.TemplatesTest do
3134
end
3235
end
3336

34-
3537
describe "index()" do
3638
setup do
3739
flags = [
3840
%Flag{name: :pineapple, gates: [Gate.new(:boolean, true)]},
39-
%Flag{name: :papaya, gates: [Gate.new(:boolean, false)]},
41+
%Flag{name: :papaya, gates: [Gate.new(:boolean, false)]}
4042
]
43+
4144
{:ok, flags: flags}
4245
end
4346

@@ -55,7 +58,6 @@ defmodule FunWithFlags.UI.TemplatesTest do
5558
end
5659
end
5760

58-
5961
describe "details()" do
6062
setup do
6163
flag = %Flag{name: :avocado, gates: []}
@@ -77,38 +79,65 @@ defmodule FunWithFlags.UI.TemplatesTest do
7779
test "it includes the CSRF token", %{conn: conn, flag: flag} do
7880
csrf_token = Plug.CSRFProtection.get_csrf_token()
7981
out = Templates.details(conn: conn, flag: flag)
80-
assert String.contains?(out, ~s{<input type="hidden" name="_csrf_token" value="#{csrf_token}">})
82+
83+
assert String.contains?(
84+
out,
85+
~s{<input type="hidden" name="_csrf_token" value="#{csrf_token}">}
86+
)
8187
end
8288

83-
test "it includes the global toggle, the new actor and new group forms, and the global delete form", %{conn: conn, flag: flag} do
89+
test "it includes the global toggle, the new actor and new group forms, and the global delete form",
90+
%{conn: conn, flag: flag} do
8491
out = Templates.details(conn: conn, flag: flag)
85-
assert String.contains?(out, ~s{<form id="fwf-global-toggle-form" action="/pear/flags/avocado/boolean" method="post"})
86-
assert String.contains?(out, ~s{<form id="fwf-new-actor-form" action="/pear/flags/avocado/actors" method="post"})
87-
assert String.contains?(out, ~s{<form id="fwf-new-group-form" action="/pear/flags/avocado/groups" method="post"})
88-
assert String.contains?(out, ~s{<form id="fwf-delete-flag-form" action="/pear/flags/avocado" method="post">})
92+
93+
assert String.contains?(
94+
out,
95+
~s{<form id="fwf-global-toggle-form" action="/pear/flags/avocado/boolean" method="post"}
96+
)
97+
98+
assert String.contains?(
99+
out,
100+
~s{<form id="fwf-new-actor-form" action="/pear/flags/avocado/actors" method="post"}
101+
)
102+
103+
assert String.contains?(
104+
out,
105+
~s{<form id="fwf-new-group-form" action="/pear/flags/avocado/groups" method="post"}
106+
)
107+
108+
assert String.contains?(
109+
out,
110+
~s{<form id="fwf-delete-flag-form" action="/pear/flags/avocado" method="post">}
111+
)
89112
end
90113

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

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

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

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

141170
assert String.contains?(out, ~s{<div id="actor_moss:123"})
142-
assert String.contains?(out, ~s{<form action="/pear/flags/avocado/actors/moss:123" method="post"})
171+
172+
assert String.contains?(
173+
out,
174+
~s{<form action="/pear/flags/avocado/actors/moss:123" method="post"}
175+
)
143176

144177
assert String.contains?(out, ~s{<div id="group_rocks"})
145-
assert String.contains?(out, ~s{<form action="/pear/flags/avocado/groups/rocks" method="post"})
178+
179+
assert String.contains?(
180+
out,
181+
~s{<form action="/pear/flags/avocado/groups/rocks" method="post"}
182+
)
146183
end
147184

148-
test "with actors and groups it contains their rows with escaped HTML and URLs", %{conn: conn, flag: flag} do
185+
test "with actors and groups it contains their rows with escaped HTML and URLs", %{
186+
conn: conn,
187+
flag: flag
188+
} do
149189
group_gate = %Gate{type: :group, for: :rocks, enabled: true}
150190
actor_gate = %Gate{type: :actor, for: "moss:<h1>123</h1>", enabled: true}
151191
flag = %Flag{flag | gates: [actor_gate, group_gate]}
152192

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

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

158202
assert String.contains?(out, ~s{<div id="group_rocks"})
159-
assert String.contains?(out, ~s{<form action="/pear/flags/avocado/groups/rocks" method="post"})
203+
204+
assert String.contains?(
205+
out,
206+
~s{<form action="/pear/flags/avocado/groups/rocks" method="post"}
207+
)
160208
end
161209
end
162210

163-
164211
describe "new()" do
165212
test "it renders", %{conn: conn} do
166213
out = Templates.new(conn: conn)
@@ -170,7 +217,11 @@ defmodule FunWithFlags.UI.TemplatesTest do
170217
test "it includes the right content", %{conn: conn} do
171218
out = Templates.new(conn: conn)
172219
assert String.contains?(out, "<title>FunWithFlags - New Flag</title>")
173-
assert String.contains?(out, ~s{<form id="new-flag-form" action="/pear/flags" method="post">})
220+
221+
assert String.contains?(
222+
out,
223+
~s{<form id="new-flag-form" action="/pear/flags" method="post">}
224+
)
174225
end
175226
end
176227

0 commit comments

Comments
 (0)