Skip to content

Commit 5b0c7ed

Browse files
Make the dashboard work under host CSRF protection pipelines
When `FunWithFlags.UI.Router` is forwarded under a host pipeline that already runs `Plug.CSRFProtection` — for example Phoenix's standard `:browser` pipeline — the host registers a `before_send` callback that raises `Plug.CSRFProtection.InvalidCrossOriginRequestError` whenever a non-XHR GET returns a JavaScript response. The dashboard serves its own `/assets/details.js` exactly that way, so the asset is rejected and the UI fails to load. The router now opts its own static asset GET/HEAD requests out of CSRF protection by setting the documented `:plug_skip_csrf_protection` flag before the asset is sent. `Plug.CSRFProtection` reads that flag at send time, so it correctly suppresses the cross-origin check even though the host registered its callback first. State-changing requests are never skipped and remain fully CSRF-protected. This means the dashboard works under the standard `:browser` pipeline with no special pipeline and no host-side configuration. The previously documented `:mounted_apps` recipe (which omitted `:protect_from_forgery`) is no longer necessary. Fixes #52. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2bd87e6 commit 5b0c7ed

4 files changed

Lines changed: 128 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## v1.2.0 (unreleased)
44

5+
* `FunWithFlags.UI.Router` now works under Phoenix's standard `:browser` pipeline (or any host pipeline that uses `Plug.CSRFProtection`) without extra configuration. The router opts its own static asset `GET`/`HEAD` requests out of the host's CSRF protection, so the dashboard's cross-origin `<script>` and `<link>` requests no longer raise `Plug.CSRFProtection.InvalidCrossOriginRequestError`. State-changing requests remain fully CSRF-protected, and the previously documented `:mounted_apps` pipeline (one that omits `:protect_from_forgery`) is no longer necessary. ([issue/52](https://github.com/tompave/fun_with_flags_ui/issues/52))
56
* Drop support for Erlang/OTP 25, and Erlang/OTP >= 26 is now required. Dropping support for older versions of Erlang/OTP simply means that this package is not tested with them in CI, and that no compatibility issues are considered bugs.
67

78
## v1.1.0

README.md

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,22 @@ It's primarily meant to be embedded in a host Plug application, either Phoenix o
1616

1717
### Mounted in Phoenix
1818

19-
The router plug can be mounted inside the Phoenix router with [`Phoenix.Router.forward/4`](https://hexdocs.pm/phoenix/Phoenix.Router.html#forward/4).
19+
The router plug can be mounted inside the Phoenix router with [`Phoenix.Router.forward/4`](https://hexdocs.pm/phoenix/Phoenix.Router.html#forward/4). It works under Phoenix's standard `:browser` pipeline, so you can reuse the same pipeline that already handles sessions, secure headers, and authentication:
2020

2121
```elixir
2222
defmodule MyPhoenixAppWeb.Router do
2323
use MyPhoenixAppWeb, :router
2424

25-
pipeline :mounted_apps do
26-
plug :accepts, ["html"]
27-
plug :put_secure_browser_headers
28-
end
29-
30-
scope path: "/feature-flags" do
31-
pipe_through :mounted_apps
25+
scope "/feature-flags" do
26+
pipe_through [:browser, :require_authenticated_admin]
3227
forward "/", FunWithFlags.UI.Router, namespace: "feature-flags"
3328
end
3429
end
3530
```
3631

37-
Note: There is no need to add `:protect_from_forgery` to the `:mounted_apps` pipeline because this package already implements CSRF protection. In order to enable it, your host application must use the `Plug.Session` plug, which is usually configured in the endpoint module in Phoenix.
32+
The dashboard serves its own JavaScript and CSS from `/assets/*`. Because browsers request those `<script>` and `<link>` tags cross-origin, a host CSRF plug such as Phoenix's `:protect_from_forgery` would otherwise reject them with `Plug.CSRFProtection.InvalidCrossOriginRequestError`. `FunWithFlags.UI.Router` handles this for you: it opts its own asset `GET`/`HEAD` requests out of CSRF protection, while every state-changing request (creating, toggling, and deleting flags) stays fully CSRF-protected. No special pipeline and no extra configuration are required.
33+
34+
CSRF protection for those state-changing requests requires your host application to use the `Plug.Session` plug, which Phoenix configures in the endpoint module by default.
3835

3936
### Mounted in another Plug application
4037

@@ -47,7 +44,7 @@ defmodule Another.App do
4744
end
4845
```
4946

50-
Note: If your plug router uses `Plug.CSRFProtection`, `FunWithFlags.UI.Router` should be added before your CSRF protection plug because it already implements its own CSRF protection. If you declare `FunWithFlags.UI.Router` after, your CSRF plug will likely block GET requests for the JS assets of the dashboard.
47+
The same asset handling described above applies here: the router opts its own asset requests out of CSRF protection, so it works regardless of where your host application's `Plug.CSRFProtection` sits in the pipeline.
5148

5249
### Standalone
5350

@@ -84,14 +81,13 @@ defmodule MyPhoenixAppWeb.Router do
8481
use MyPhoenixAppWeb, :router
8582
+ import Plug.BasicAuth
8683

87-
pipeline :mounted_apps do
88-
plug :accepts, ["html"]
89-
plug :put_secure_browser_headers
84+
+ pipeline :feature_flags_auth do
9085
+ plug :basic_auth, username: "foo", password: "bar"
91-
end
86+
+ end
9287

93-
scope path: "/feature-flags" do
94-
pipe_through :mounted_apps
88+
scope "/feature-flags" do
89+
- pipe_through :browser
90+
+ pipe_through [:browser, :feature_flags_auth]
9591
forward "/", FunWithFlags.UI.Router, namespace: "feature-flags"
9692
end
9793
end

lib/fun_with_flags/ui/router.ex

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ defmodule FunWithFlags.UI.Router do
1414

1515
plug Plug.Logger, log: :debug
1616

17+
# When the UI is forwarded under a host pipeline that already runs
18+
# `Plug.CSRFProtection` (for example, Phoenix's standard `:browser`
19+
# pipeline), that plug has registered a `before_send` callback that raises
20+
# `Plug.CSRFProtection.InvalidCrossOriginRequestError` whenever a non-XHR
21+
# `GET` returns a JavaScript response. The dashboard's `<script>` tag for
22+
# `/assets/details.js` is exactly such a request, so the host would reject
23+
# the asset and the UI would fail to load.
24+
#
25+
# `Plug.CSRFProtection` reads `conn.private[:plug_skip_csrf_protection]` at
26+
# send time, so setting it here — after the host registered its callback but
27+
# before the asset is sent — suppresses that check for asset requests only.
28+
# It must run before `Plug.Static` sends the response.
29+
plug :skip_csrf_for_assets
30+
1731
plug Plug.Static,
1832
gzip: true,
1933
at: "/assets",
@@ -293,6 +307,26 @@ defmodule FunWithFlags.UI.Router do
293307
end
294308

295309

310+
@safe_methods ~w(GET HEAD)
311+
312+
# Opt safe asset requests out of CSRF protection (both the host's and this
313+
# router's own) by setting the documented `:plug_skip_csrf_protection`
314+
# escape hatch. Only `GET`/`HEAD` requests for `/assets/<file>` are skipped:
315+
# those are read-only and are the only requests that trip the host's
316+
# cross-origin-JavaScript check. State-changing requests are never skipped
317+
# and continue to flow through `protect_from_forgery` unchanged.
318+
#
319+
# `forward` strips the mount prefix from `conn.path_info`, so the asset
320+
# subtree is always `["assets" | file]` here regardless of where the router
321+
# is mounted — no mount/namespace configuration is needed.
322+
defp skip_csrf_for_assets(%Plug.Conn{method: method, path_info: ["assets", _ | _]} = conn, _opts)
323+
when method in @safe_methods do
324+
Plug.Conn.put_private(conn, :plug_skip_csrf_protection, true)
325+
end
326+
327+
defp skip_csrf_for_assets(conn, _opts), do: conn
328+
329+
296330
# Custom CSRF protection plug. It wraps the default plug provided
297331
# by `Plug`, it calls `Plug.Conn.fetch_session/1` (no-op if already
298332
# fetched), and it bails out gracefully if no session is configured.

test/fun_with_flags/ui/router_test.exs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,87 @@ defmodule FunWithFlags.UI.RouterTest do
200200
end
201201

202202

203+
# Simulates a host application (for example, a Phoenix `:browser` pipeline)
204+
# that runs `Plug.CSRFProtection` *around* the forwarded router. The host's
205+
# CSRF plug registers a `before_send` callback that raises
206+
# `InvalidCrossOriginRequestError` for non-XHR `GET`s returning JavaScript.
207+
# The router must suppress that for its own assets so the dashboard loads.
208+
describe "embedded under a host CSRF pipeline" do
209+
@session_opts Plug.Session.init(
210+
store: :cookie,
211+
key: "_test_session",
212+
signing_salt: "test-salt",
213+
encryption_salt: "test-enc"
214+
)
215+
@csrf_opts Plug.CSRFProtection.init([])
216+
217+
defp through_host_csrf(conn) do
218+
conn
219+
|> Map.put(:secret_key_base, String.duplicate("a", 64))
220+
|> Plug.Session.call(@session_opts)
221+
|> fetch_session()
222+
|> Plug.CSRFProtection.call(@csrf_opts)
223+
|> Router.call(@opts)
224+
end
225+
226+
test "a JavaScript asset loads without raising InvalidCrossOriginRequestError" do
227+
conn = through_host_csrf(conn(:get, "/assets/details.js"))
228+
229+
assert conn.status == 200
230+
assert conn.private[:plug_skip_csrf_protection] == true
231+
# `Plug.Static` serves `.js` as `text/javascript`, which is exactly the
232+
# content type the host's CSRF `before_send` callback would reject.
233+
assert ["text/javascript"] = get_resp_header(conn, "content-type")
234+
end
235+
236+
test "a CSS asset loads as well" do
237+
conn = through_host_csrf(conn(:get, "/assets/style.css"))
238+
239+
assert conn.status == 200
240+
assert conn.private[:plug_skip_csrf_protection] == true
241+
end
242+
243+
test "an HTML page is served normally and is not opted out of CSRF" do
244+
conn = through_host_csrf(conn(:get, "/flags"))
245+
246+
assert conn.status == 200
247+
refute conn.private[:plug_skip_csrf_protection]
248+
end
249+
250+
test "a forged state-changing request is still rejected" do
251+
# No `_csrf_token`, so the host CSRF plug rejects it before the router
252+
# can act, and the asset skip never applies to mutations.
253+
assert_raise Plug.CSRFProtection.InvalidCSRFTokenError, fn ->
254+
through_host_csrf(conn(:post, "/flags", "flag_name=nope"))
255+
end
256+
end
257+
end
258+
259+
260+
describe "asset CSRF skip" do
261+
test "is set only for safe methods on /assets/<file>" do
262+
for method <- [:get, :head] do
263+
conn = conn(method, "/assets/details.js") |> Router.call(@opts)
264+
assert conn.private[:plug_skip_csrf_protection] == true,
265+
"expected #{method} /assets/details.js to be skipped"
266+
end
267+
end
268+
269+
test "is not set for non-asset paths" do
270+
for path <- ["/flags", "/new", "/"] do
271+
conn = conn(:get, path) |> Router.call(@opts)
272+
refute conn.private[:plug_skip_csrf_protection],
273+
"expected #{path} to retain CSRF protection"
274+
end
275+
end
276+
277+
test "is not set for the bare /assets path with no file" do
278+
conn = conn(:get, "/assets") |> Router.call(@opts)
279+
refute conn.private[:plug_skip_csrf_protection]
280+
end
281+
end
282+
283+
203284
# For GET and DELETE
204285
#
205286
defp request!(method, path) do

0 commit comments

Comments
 (0)