Skip to content

Commit 7641fb4

Browse files
Harden DataTable sort and selection helpers against forged client params (#46)
Whitelist sort columns via sort_columns, ignore invalid sort_by without crashing, and only accept row ids that exist in the current rows assign.
1 parent 028e2cd commit 7641fb4

5 files changed

Lines changed: 120 additions & 21 deletions

File tree

lib/components/data_table.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,11 @@ defmodule Corex.DataTable do
9999
# mount
100100
socket
101101
|> assign(:users, users)
102-
|> Corex.DataTable.Sort.assign_for_sort(:users, default_sort_by: :id, default_sort_order: :asc)
102+
|> Corex.DataTable.Sort.assign_for_sort(:users,
103+
default_sort_by: :id,
104+
default_sort_order: :asc,
105+
sort_columns: [:id, :name]
106+
)
103107
104108
# handle_event("sort", params, socket)
105109
{:noreply, Corex.DataTable.Sort.handle_sort(socket, params, :users)}

lib/components/data_table/selection.ex

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,22 +74,34 @@ defmodule Corex.DataTable.Selection do
7474
Use in `handle_event("select", params, socket)` (same name as `on_select`) and return
7575
`{:noreply, Corex.DataTable.Selection.handle_select(socket, params, :users)}`.
7676
77-
`params` must contain `"id"` (checkbox DOM id) and `"checked"`. `rows_assign`
78-
is the assign key passed to [`data_table/1`](Corex.DataTable.html#data_table/1) as `rows` (e.g. `:users`).
77+
`params` must contain `"id"` (checkbox DOM id) and `"checked"`. Only row ids derived from
78+
current `rows` via `selection_row_id` are added to `:selected`; forged ids are ignored when
79+
checking a row. `rows_assign` is the assign key passed to [`data_table/1`](Corex.DataTable.html#data_table/1) as `rows` (e.g. `:users`).
7980
"""
8081
def handle_select(socket, %{"id" => checkbox_id, "checked" => checked}, rows_assign) do
8182
table_id = socket.assigns.selection_table_id
83+
row_id_fn = socket.assigns.selection_row_id
8284
row_id = String.replace(checkbox_id, "#{table_id}-select-", "")
8385
rows = socket.assigns[rows_assign] || []
86+
valid_ids = valid_row_ids(rows, row_id_fn)
8487

8588
selected =
86-
if checked do
87-
[row_id | socket.assigns.selected] |> Enum.uniq()
88-
else
89-
List.delete(socket.assigns.selected, row_id)
89+
cond do
90+
checked and MapSet.member?(valid_ids, row_id) ->
91+
[row_id | socket.assigns.selected] |> Enum.uniq()
92+
93+
checked ->
94+
socket.assigns.selected
95+
96+
true ->
97+
List.delete(socket.assigns.selected, row_id)
9098
end
9199

92-
all_selected = length(selected) == length(rows)
100+
selected = Enum.filter(selected, &MapSet.member?(valid_ids, &1))
101+
102+
all_selected =
103+
MapSet.subset?(MapSet.new(selected), valid_ids) and
104+
length(selected) == MapSet.size(valid_ids)
93105

94106
socket
95107
|> assign(:selected, selected)
@@ -129,4 +141,10 @@ defmodule Corex.DataTable.Selection do
129141

130142
socket
131143
end
144+
145+
defp valid_row_ids(rows, row_id_fn) when is_function(row_id_fn, 1) do
146+
rows
147+
|> Enum.map(row_id_fn)
148+
|> MapSet.new()
149+
end
132150
end

lib/components/data_table/sort.ex

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ defmodule Corex.DataTable.Sort do
1515
socket =
1616
socket
1717
|> assign(:users, fetch_users())
18-
|> Corex.DataTable.Sort.assign_for_sort(:users, default_sort_by: :id, default_sort_order: :asc)
18+
|> Corex.DataTable.Sort.assign_for_sort(:users,
19+
default_sort_by: :id,
20+
default_sort_order: :asc,
21+
sort_columns: [:id, :name]
22+
)
1923
2024
{:ok, socket}
2125
end
@@ -47,19 +51,24 @@ defmodule Corex.DataTable.Sort do
4751
4852
- `:default_sort_by` – atom; column `name` on [`data_table/1`](Corex.DataTable.html#data_table/1) (e.g. `:id`)
4953
- `:default_sort_order` – `:asc` or `:desc`, default `:asc`
54+
- `:sort_columns` – list of atoms the client may sort by (e.g. `[:id, :name]`). When set,
55+
[`handle_sort/3`](#handle_sort/3) ignores unknown or disallowed `"sort_by"` values instead of
56+
raising. Always set this in production LiveViews.
5057
5158
The socket must already have an assign at `rows_assign` (e.g. `:users`) with the same list
5259
passed as `rows` to [`data_table/1`](Corex.DataTable.html#data_table/1). Adds `:sort_by`,
53-
`:sort_order`, and replaces the rows assign with the sorted list.
60+
`:sort_order`, `:sort_columns`, and replaces the rows assign with the sorted list.
5461
"""
5562
def assign_for_sort(socket, rows_assign, opts \\ []) do
5663
sort_by = Keyword.get(opts, :default_sort_by)
5764
sort_order = Keyword.get(opts, :default_sort_order, :asc)
65+
sort_columns = Keyword.get(opts, :sort_columns)
5866
rows = socket.assigns[rows_assign] || []
5967

6068
socket
6169
|> assign(:sort_by, sort_by)
6270
|> assign(:sort_order, sort_order)
71+
|> assign(:sort_columns, sort_columns)
6372
|> assign(rows_assign, sort_rows(rows, sort_by, sort_order))
6473
end
6574

@@ -69,11 +78,19 @@ defmodule Corex.DataTable.Sort do
6978
Use in `handle_event("sort", params, socket)` (same name as `on_sort`) and return
7079
`{:noreply, Corex.DataTable.Sort.handle_sort(socket, params, :users)}`.
7180
72-
`params` must contain `"sort_by"` (string, e.g. `"id"`). It is converted to an atom.
81+
`params` must contain `"sort_by"` (string, e.g. `"id"`). When `:sort_columns` was set via
82+
[`assign_for_sort/3`](#assign_for_sort/3), only those columns are accepted; other values are
83+
ignored and the socket is returned unchanged. Unknown atoms never crash the LiveView process.
7384
`rows_assign` is the assign key passed to [`data_table/1`](Corex.DataTable.html#data_table/1) as `rows`.
7485
"""
7586
def handle_sort(socket, %{"sort_by" => sort_by_param}, rows_assign) do
76-
sort_by = String.to_existing_atom(sort_by_param)
87+
case parse_sort_by(sort_by_param, socket.assigns[:sort_columns]) do
88+
{:ok, sort_by} -> apply_sort(socket, sort_by, rows_assign)
89+
:error -> socket
90+
end
91+
end
92+
93+
defp apply_sort(socket, sort_by, rows_assign) do
7794
current_sort_by = socket.assigns.sort_by
7895
current_sort_order = socket.assigns.sort_order
7996

@@ -92,6 +109,22 @@ defmodule Corex.DataTable.Sort do
92109
|> assign(rows_assign, sort_rows(rows, sort_by, sort_order))
93110
end
94111

112+
defp parse_sort_by(param, columns) when is_list(columns) do
113+
with {:ok, sort_by} <- safe_existing_atom(param), true <- sort_by in columns do
114+
{:ok, sort_by}
115+
else
116+
_ -> :error
117+
end
118+
end
119+
120+
defp parse_sort_by(param, _columns), do: safe_existing_atom(param)
121+
122+
defp safe_existing_atom(param) when is_binary(param) do
123+
{:ok, String.to_existing_atom(param)}
124+
rescue
125+
ArgumentError -> :error
126+
end
127+
95128
defp toggle_sort_order(:asc), do: :desc
96129
defp toggle_sort_order(:desc), do: :asc
97130

test/components/data_table_selection_test.exs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,26 @@ defmodule Corex.DataTable.SelectionTest do
4949

5050
assert Enum.sort(socket.assigns.selected) == ["1", "2"]
5151
end
52+
53+
test "ignores forged row id when checking" do
54+
socket = base_socket()
55+
56+
socket =
57+
Selection.handle_select(socket, %{"id" => "tbl-select-forged", "checked" => true}, :users)
58+
59+
assert socket.assigns.selected == []
60+
end
61+
62+
test "drops stale selected ids not in current rows" do
63+
socket =
64+
base_socket()
65+
|> assign(:selected, ["1", "stale"])
66+
67+
socket =
68+
Selection.handle_select(socket, %{"id" => "tbl-select-2", "checked" => true}, :users)
69+
70+
assert Enum.sort(socket.assigns.selected) == ["1", "2"]
71+
end
5272
end
5373

5474
describe "handle_select_all/3" do

test/components/data_table_sort_test.exs

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@ defmodule Corex.DataTable.SortTest do
1414
socket =
1515
%Phoenix.LiveView.Socket{}
1616
|> assign(:users, rows())
17-
|> Sort.assign_for_sort(:users, default_sort_by: :id, default_sort_order: :asc)
17+
|> Sort.assign_for_sort(:users,
18+
default_sort_by: :id,
19+
default_sort_order: :asc,
20+
sort_columns: [:id, :name]
21+
)
1822

1923
assert socket.assigns.sort_by == :id
2024
assert socket.assigns.sort_order == :asc
25+
assert socket.assigns.sort_columns == [:id, :name]
2126
assert Enum.map(socket.assigns.users, & &1.id) == [1, 2]
2227
end
2328

@@ -34,11 +39,16 @@ defmodule Corex.DataTable.SortTest do
3439
end
3540

3641
describe "handle_sort/3" do
42+
defp sorted_socket(opts \\ []) do
43+
defaults = [default_sort_by: :id, default_sort_order: :asc, sort_columns: [:id, :name]]
44+
45+
%Phoenix.LiveView.Socket{}
46+
|> assign(:users, rows())
47+
|> Sort.assign_for_sort(:users, Keyword.merge(defaults, opts))
48+
end
49+
3750
test "sorts by new column ascending" do
38-
socket =
39-
%Phoenix.LiveView.Socket{}
40-
|> assign(:users, rows())
41-
|> Sort.assign_for_sort(:users, default_sort_by: :id, default_sort_order: :asc)
51+
socket = sorted_socket()
4252

4353
socket = Sort.handle_sort(socket, %{"sort_by" => "name"}, :users)
4454

@@ -48,14 +58,28 @@ defmodule Corex.DataTable.SortTest do
4858
end
4959

5060
test "toggles order when sorting same column" do
51-
socket =
52-
%Phoenix.LiveView.Socket{}
53-
|> assign(:users, rows())
54-
|> Sort.assign_for_sort(:users, default_sort_by: :name, default_sort_order: :asc)
61+
socket = sorted_socket(default_sort_by: :name)
5562

5663
socket = Sort.handle_sort(socket, %{"sort_by" => "name"}, :users)
5764

5865
assert socket.assigns.sort_order == :desc
5966
end
67+
68+
test "ignores unknown sort_by atom" do
69+
socket = sorted_socket()
70+
71+
assert socket == Sort.handle_sort(socket, %{"sort_by" => "not_a_column"}, :users)
72+
end
73+
74+
test "ignores sort_by not in sort_columns whitelist" do
75+
socket = sorted_socket(sort_columns: [:id])
76+
77+
before = socket.assigns
78+
socket = Sort.handle_sort(socket, %{"sort_by" => "name"}, :users)
79+
80+
assert socket.assigns.sort_by == before.sort_by
81+
assert socket.assigns.sort_order == before.sort_order
82+
assert socket.assigns.users == before.users
83+
end
6084
end
6185
end

0 commit comments

Comments
 (0)