Skip to content

Commit 5f0ed57

Browse files
GazlerSteffenDE
andauthored
Raise if there are too many redirects without a render (#3670)
* Raise if there are too many redirects without a render In circumstances where handle_params does a patch redirect, it is possible for a user to get stuck in an infinite loop. This can be expensive if the application performs any querying inside of handle_params/3 For a dead render, the browser will handle this, as it will give an `ERR_TOO_MANY_REDIRECTS` - however for a patch, the browser never actually handles the redirect. This commit simply stores a counter every time a patch occurs, and if there's no render between patches, then it will raise, giving the developer a clue where to look for the loop. A limit of 20 was chosen to match what chrome uses for its `ERR_TOO_MANY_REDIRECTS` implementation. ``` Mix.install([ {:phoenix_playground, "~> 0.1.6"}, {:phoenix_live_view, "~> 1.0.4"} ]) defmodule DemoLive do use Phoenix.LiveView def mount(_params, _session, socket) do {:ok, assign(socket, permitted: true, count: 0)} end def handle_params(params, _uri, socket) do socket = assign(socket, :count, socket.assigns.count + 1) if socket.assigns.permitted do {:noreply, socket} else {:noreply, push_patch(socket, to: "/?count=#{socket.assigns.count}")} end end def handle_event("toggle", _params, socket) do {:noreply, assign(socket, permitted: !socket.assigns.permitted)} end def handle_event("patch", _params, socket) do {:noreply, push_patch(socket, to: "/?count=#{socket.assigns.count}")} end def render(assigns) do ~H""" <div> {if @permitted, do: "Permitted", else: "Not Permitted"} <button phx-click="toggle">{if @permitted, do: "Disable", else: "Enable"}</button> <button phx-click="patch">Patch Redirect</button> </div> """ end end PhoenixPlayground.start(live: DemoLive, port: 4200) ``` * set low reload jitter for test * Store redirect count on state instead of in process dictionary --------- Co-authored-by: Steffen Deusch <[email protected]>
1 parent abad050 commit 5f0ed57

File tree

4 files changed

+75
-1
lines changed

4 files changed

+75
-1
lines changed

lib/phoenix_live_view/channel.ex

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,7 @@ defmodule Phoenix.LiveView.Channel do
812812
{:diff, diff, new_state} ->
813813
{:noreply,
814814
new_state
815+
|> clear_live_patch_counter()
815816
|> push_live_patch(pending_live_patch)
816817
|> push_diff(diff, ref)}
817818

@@ -820,6 +821,24 @@ defmodule Phoenix.LiveView.Channel do
820821
end
821822
end
822823

824+
defp check_patch_redirect_limit!(state) do
825+
current = state.redirect_count
826+
827+
if current == 20 do
828+
raise RuntimeError, """
829+
too many redirects for #{inspect(state.socket.view)} on action #{inspect(state.socket.assigns.live_action)}
830+
831+
Check the `handle_params/3` callback for an infinite patch redirect loop
832+
"""
833+
else
834+
%{state | redirect_count: current + 1}
835+
end
836+
end
837+
838+
defp clear_live_patch_counter(state) do
839+
%{state | redirect_count: 0}
840+
end
841+
823842
defp handle_redirect(new_state, result, flash, ref) do
824843
%{socket: new_socket} = new_state
825844
root_pid = new_socket.root_pid
@@ -857,6 +876,7 @@ defmodule Phoenix.LiveView.Channel do
857876

858877
new_state
859878
|> drop_redirect()
879+
|> check_patch_redirect_limit!()
860880
|> Map.update!(:socket, &Utils.replace_flash(&1, flash))
861881
|> sync_handle_params_with_live_redirect(params, action, opts, ref)
862882

@@ -1377,6 +1397,7 @@ defmodule Phoenix.LiveView.Channel do
13771397
topic: phx_socket.topic,
13781398
components: Diff.new_components(),
13791399
fingerprints: Diff.new_fingerprints(),
1400+
redirect_count: 0,
13801401
upload_names: %{},
13811402
upload_pids: %{}
13821403
}

test/e2e/support/navigation.ex

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ defmodule Phoenix.LiveViewTest.E2E.Navigation.Layout do
99
<script type="module">
1010
import {LiveSocket} from "/assets/phoenix_live_view/phoenix_live_view.esm.js"
1111
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
12-
let liveSocket = new LiveSocket("/live", window.Phoenix.Socket, {params: {_csrf_token: csrfToken}})
12+
let liveSocket = new LiveSocket("/live", window.Phoenix.Socket, {
13+
params: {_csrf_token: csrfToken},
14+
reloadJitterMin: 50,
15+
reloadJitterMax: 500
16+
})
1317
liveSocket.connect()
1418
window.liveSocket = liveSocket
1519
@@ -185,6 +189,36 @@ defmodule Phoenix.LiveViewTest.E2E.Navigation.Dead do
185189
end
186190
end
187191

192+
defmodule Phoenix.LiveViewTest.E2E.Navigation.RedirectLoopLive do
193+
use Phoenix.LiveView
194+
195+
@impl Phoenix.LiveView
196+
def mount(params, _session, socket) do
197+
if params["loop"] do
198+
{:ok, assign(socket, message: "Too many redirects", loop: false)}
199+
else
200+
{:ok, assign(socket, message: nil, loop: true)}
201+
end
202+
end
203+
204+
@impl Phoenix.LiveView
205+
def handle_params(params, _uri, socket) do
206+
if params["loop"] && socket.assigns.loop do
207+
{:noreply, push_patch(socket, to: "/navigation/redirectloop?loop=true")}
208+
else
209+
{:noreply, socket}
210+
end
211+
end
212+
213+
@impl Phoenix.LiveView
214+
def render(assigns) do
215+
~H"""
216+
<div :if={@message} id="message">{@message}</div>
217+
<.link patch="/navigation/redirectloop?loop=true">Redirect Loop</.link>
218+
"""
219+
end
220+
end
221+
188222
defmodule Phoenix.LiveViewTest.E2E.Navigation.DeadHTML do
189223
use Phoenix.Component
190224

test/e2e/test_helper.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ defmodule Phoenix.LiveViewTest.E2E.Router do
172172
live "/a", Phoenix.LiveViewTest.E2E.Navigation.ALive
173173
live "/b", Phoenix.LiveViewTest.E2E.Navigation.BLive, :index
174174
live "/b/:id", Phoenix.LiveViewTest.E2E.Navigation.BLive, :show
175+
live "/redirectloop", Phoenix.LiveViewTest.E2E.Navigation.RedirectLoopLive, :index
175176
get "/dead", Phoenix.LiveViewTest.E2E.Navigation.Dead, :index
176177
end
177178
end

test/e2e/tests/navigation.spec.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,24 @@ test("can navigate between LiveViews in the same live session over websocket", a
6464
]))
6565
})
6666

67+
test("handles live redirect loops", async ({page}) => {
68+
await page.goto("/navigation/redirectloop")
69+
await syncLV(page)
70+
71+
await page.getByRole("link", {name: "Redirect Loop"}).click()
72+
73+
await expect(async () => {
74+
expect(webSocketEvents).toEqual(expect.arrayContaining([
75+
expect.objectContaining({type: "received", payload: expect.stringContaining("phx_error")}),
76+
]))
77+
}).toPass()
78+
79+
// We need to wait for the LV to reconnect
80+
await syncLV(page)
81+
const message = page.locator("#message")
82+
await expect(message).toHaveText("Too many redirects")
83+
})
84+
6785
test("popstate", async ({page}) => {
6886
await page.goto("/navigation/a")
6987
await syncLV(page)

0 commit comments

Comments
 (0)