Skip to content

Commit 040f20d

Browse files
solnicclaude
andauthored
fix(live_view): scrub sensitive data from LiveView breadcrumbs (#1051)
* fix(live_view): scrub sensitive data from LiveView breadcrumbs Sentry.LiveViewHook previously stored raw event params, handle_params params, and URIs directly in breadcrumbs. Form submissions over the LiveView WebSocket frequently contain passwords, tokens, and other secrets, which were forwarded to Sentry unredacted. The hook now passes breadcrumb data through Sentry.Scrubber.scrub_map/2 and URIs through Sentry.Scrubber.scrub_url/2 before adding them to the breadcrumb trail. Users can override the scrubber by passing a {module, function, args} tuple via on_mount opts, mirroring the override mechanism already provided by Sentry.PlugCapture: on_mount {Sentry.LiveViewHook, scrubber: {MyApp.Scrubber, :scrub, []}} Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(live_view): scrub uri too from connect info * test(live_view): add test to scrub sensitive params from mount breadcrumb * feat(live_view): cover scrubbing error paths in tests * docs(live_view): clarify scrubber resolution timing in docs * fix(live_view): handle scrubber errors gracefully --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 1dd0ef9 commit 040f20d

2 files changed

Lines changed: 258 additions & 10 deletions

File tree

lib/sentry/live_view_hook.ex

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,31 @@ if Code.ensure_loaded?(Phoenix.LiveView) do
3939
4040
You can also set this in your `MyAppWeb` module, so that all LiveViews that
4141
`use MyAppWeb, :live_view` will have this hook.
42+
43+
## Scrubbing Sensitive Data
44+
45+
*Available since v13.1.0.*
46+
47+
LiveView events and `handle_params` calls frequently carry user-submitted
48+
form data, which may include passwords or other sensitive values. Before
49+
storing this data in breadcrumbs, this hook scrubs it using
50+
`Sentry.Scrubber.scrub_map/2`. URI query strings stored in breadcrumbs are
51+
scrubbed via `Sentry.Scrubber.scrub_url/2`.
52+
53+
To customize the scrubbing logic, pass a `:scrubber` option when attaching
54+
the hook. The scrubber must be a `{module, function, args}` tuple; the
55+
breadcrumb `data` map is prepended to `args` before invoking the function,
56+
which must return a map.
57+
58+
on_mount {Sentry.LiveViewHook, scrubber: {MyApp.Scrubber, :scrub, []}}
59+
60+
The default scrubber is equivalent to:
61+
62+
{Sentry.LiveViewHook, :default_scrubber, []}
63+
64+
The scrubber is resolved once at `on_mount` time and applies to every
65+
breadcrumb recorded for the lifetime of the LiveView process.
66+
4267
"""
4368

4469
@moduledoc since: "10.5.0"
@@ -49,28 +74,98 @@ if Code.ensure_loaded?(Phoenix.LiveView) do
4974

5075
require Logger
5176

77+
@scrubber_pdict_key {__MODULE__, :scrubber}
78+
5279
# See also:
5380
# https://develop.sentry.dev/sdk/event-payloads/request/
5481

5582
@doc false
56-
@spec on_mount(:default, map() | :not_mounted_at_router, map(), struct()) :: {:cont, struct()}
57-
def on_mount(:default, %{} = params, _session, socket), do: on_mount(params, socket)
58-
def on_mount(:default, :not_mounted_at_router, _session, socket), do: {:cont, socket}
83+
@spec on_mount(:default | keyword(), map() | :not_mounted_at_router, map(), struct()) ::
84+
{:cont, struct()}
85+
def on_mount(:default, params, session, socket),
86+
do: on_mount([], params, session, socket)
87+
88+
def on_mount(opts, %{} = params, _session, socket) when is_list(opts) do
89+
store_scrubber(opts)
90+
on_mount(params, socket)
91+
end
92+
93+
def on_mount(opts, :not_mounted_at_router, _session, socket) when is_list(opts) do
94+
store_scrubber(opts)
95+
{:cont, socket}
96+
end
97+
98+
@doc """
99+
The default scrubber applied to LiveView breadcrumb data.
100+
101+
Delegates to `Sentry.Scrubber.scrub_map/2` with the default sensitive
102+
parameter keys.
103+
"""
104+
@doc since: "13.1.0"
105+
@spec default_scrubber(map()) :: map()
106+
def default_scrubber(data) when is_map(data) do
107+
Sentry.Scrubber.scrub_map(data)
108+
end
59109

60110
## Helpers
61111

112+
defp store_scrubber(opts) do
113+
case Keyword.get(opts, :scrubber, {__MODULE__, :default_scrubber, []}) do
114+
{mod, fun, args} = scrubber when is_atom(mod) and is_atom(fun) and is_list(args) ->
115+
Process.put(@scrubber_pdict_key, scrubber)
116+
117+
other ->
118+
raise ArgumentError,
119+
"expected :scrubber to be a {module, function, args} tuple, got: #{inspect(other)}"
120+
end
121+
end
122+
123+
defp scrub(data) when is_map(data) do
124+
{mod, fun, args} =
125+
Process.get(@scrubber_pdict_key, {__MODULE__, :default_scrubber, []})
126+
127+
try do
128+
case apply(mod, fun, [data | args]) do
129+
result when is_map(result) ->
130+
result
131+
132+
other ->
133+
Logger.error(
134+
"Sentry.LiveViewHook scrubber returned non-map value: #{inspect(other)}; " <>
135+
"falling back to redacted data",
136+
event_source: :logger
137+
)
138+
139+
%{}
140+
end
141+
catch
142+
# We must NEVER raise an error in a hook, as it will crash the LiveView process
143+
# and we don't want Sentry to be responsible for that.
144+
kind, reason ->
145+
Logger.error(
146+
"Sentry.LiveViewHook scrubber raised an error: #{Exception.format(kind, reason)}; " <>
147+
"falling back to redacted data",
148+
event_source: :logger
149+
)
150+
151+
%{}
152+
end
153+
end
154+
155+
defp scrub_uri(uri) when is_binary(uri), do: Sentry.Scrubber.scrub_url(uri)
156+
62157
defp on_mount(params, %Phoenix.LiveView.Socket{} = socket) do
63158
Context.set_extra_context(%{socket_id: socket.id})
64159
Context.set_request_context(%{url: socket.host_uri})
65160

66161
Context.add_breadcrumb(%{
67162
category: "web.live_view.mount",
68163
message: "Mounted live view",
69-
data: params
164+
data: scrub(params)
70165
})
71166

72167
if uri = get_connect_info_if_root(socket, :uri) do
73-
Context.set_request_context(%{url: URI.to_string(uri)})
168+
Context.set_request_context(%{url: uri |> URI.to_string() |> scrub_uri()})
74169
end
75170

76171
if user_agent = get_connect_info_if_root(socket, :user_agent) do
@@ -105,7 +200,7 @@ if Code.ensure_loaded?(Phoenix.LiveView) do
105200
Context.add_breadcrumb(%{
106201
category: "web.live_view.event",
107202
message: inspect(event),
108-
data: %{event: event, params: params}
203+
data: scrub(%{event: event, params: params})
109204
})
110205

111206
{:cont, socket}
@@ -121,13 +216,14 @@ if Code.ensure_loaded?(Phoenix.LiveView) do
121216
end
122217

123218
defp handle_params_hook(params, uri, socket) do
219+
scrubbed_uri = scrub_uri(uri)
124220
Context.set_extra_context(%{socket_id: socket.id})
125-
Context.set_request_context(%{url: uri})
221+
Context.set_request_context(%{url: scrubbed_uri})
126222

127223
Context.add_breadcrumb(%{
128224
category: "web.live_view.params",
129-
message: "#{uri}",
130-
data: %{params: params, uri: uri}
225+
message: "#{scrubbed_uri}",
226+
data: scrub(%{params: params, uri: scrubbed_uri})
131227
})
132228

133229
{:cont, socket}

test/sentry/live_view_hook_test.exs

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ defmodule SentryTest.Live do
1414
{:ok, socket}
1515
end
1616

17-
def handle_event("refresh", _params, socket) do
17+
def handle_event(_event, _params, socket) do
1818
{:noreply, socket}
1919
end
2020

@@ -23,6 +23,54 @@ defmodule SentryTest.Live do
2323
end
2424
end
2525

26+
defmodule SentryTest.CustomScrubber do
27+
def scrub(data), do: Sentry.Scrubber.scrub_map(data, keys: ["api_key"])
28+
end
29+
30+
defmodule SentryTest.CustomScrubberLive do
31+
use Phoenix.LiveView
32+
33+
on_mount {Sentry.LiveViewHook, scrubber: {SentryTest.CustomScrubber, :scrub, []}}
34+
35+
def render(assigns), do: ~H"<h1>custom</h1>"
36+
37+
def mount(_params, _session, socket), do: {:ok, socket}
38+
39+
def handle_event(_event, _params, socket), do: {:noreply, socket}
40+
end
41+
42+
defmodule SentryTest.NonMapScrubber do
43+
def scrub(_data), do: :not_a_map
44+
end
45+
46+
defmodule SentryTest.NonMapScrubberLive do
47+
use Phoenix.LiveView
48+
49+
on_mount {Sentry.LiveViewHook, scrubber: {SentryTest.NonMapScrubber, :scrub, []}}
50+
51+
def render(assigns), do: ~H"<h1>nonmap</h1>"
52+
53+
def mount(_params, _session, socket), do: {:ok, socket}
54+
55+
def handle_event(_event, _params, socket), do: {:noreply, socket}
56+
end
57+
58+
defmodule SentryTest.RaisingScrubber do
59+
def scrub(_data), do: raise("scrubber crashed!")
60+
end
61+
62+
defmodule SentryTest.RaisingScrubberLive do
63+
use Phoenix.LiveView
64+
65+
on_mount {Sentry.LiveViewHook, scrubber: {SentryTest.RaisingScrubber, :scrub, []}}
66+
67+
def render(assigns), do: ~H"<h1>raising</h1>"
68+
69+
def mount(_params, _session, socket), do: {:ok, socket}
70+
71+
def handle_event(_event, _params, socket), do: {:noreply, socket}
72+
end
73+
2674
defmodule SentryTest.LiveComponent do
2775
use Phoenix.LiveComponent
2876

@@ -66,6 +114,9 @@ defmodule SentryTest.Router do
66114
scope "/" do
67115
get "/dead_test", SentryTest.PageController, :page
68116
live "/hook_test", SentryTest.Live
117+
live "/custom_scrubber", SentryTest.CustomScrubberLive
118+
live "/non_map_scrubber", SentryTest.NonMapScrubberLive
119+
live "/raising_scrubber", SentryTest.RaisingScrubberLive
69120
end
70121
end
71122

@@ -164,6 +215,107 @@ defmodule Sentry.LiveViewHookTest do
164215
assert Logger.metadata() == []
165216
end
166217

218+
test "scrubs sensitive data from breadcrumbs by default", %{conn: conn} do
219+
{:ok, view, _html} = live(conn, "/hook_test")
220+
221+
render_hook(view, :login, %{
222+
"email" => "user@example.com",
223+
"password" => "supersecret",
224+
"card" => "4111111111111111"
225+
})
226+
227+
[event_breadcrumb | _] = get_sentry_context(view).breadcrumbs
228+
229+
assert event_breadcrumb.data == %{
230+
event: "login",
231+
params: %{
232+
"email" => "user@example.com",
233+
"password" => "*********",
234+
"card" => "*********"
235+
}
236+
}
237+
end
238+
239+
test "scrubs sensitive params from mount breadcrumb", %{conn: conn} do
240+
{:ok, view, _html} = live(conn, "/hook_test?password=supersecret&visible=ok")
241+
242+
breadcrumbs = get_sentry_context(view).breadcrumbs
243+
mount_breadcrumb = Enum.find(breadcrumbs, &(&1.category == "web.live_view.mount"))
244+
245+
assert mount_breadcrumb.data == %{"password" => "*********", "visible" => "ok"}
246+
end
247+
248+
test "scrubs sensitive query params from URI in handle_params breadcrumb", %{conn: conn} do
249+
{:ok, view, _html} = live(conn, "/hook_test?password=supersecret&visible=ok")
250+
251+
context = get_sentry_context(view)
252+
params_breadcrumb = Enum.find(context.breadcrumbs, &(&1.category == "web.live_view.params"))
253+
254+
refute params_breadcrumb.data.uri =~ "supersecret"
255+
assert params_breadcrumb.data.uri =~ "password=%2A%2A%2A%2A%2A%2A%2A%2A%2A"
256+
assert params_breadcrumb.data.uri =~ "visible=ok"
257+
258+
refute context.request.url =~ "supersecret"
259+
assert context.request.url =~ "password=%2A%2A%2A%2A%2A%2A%2A%2A%2A"
260+
end
261+
262+
test "raises ArgumentError when :scrubber is not an MFA tuple" do
263+
assert_raise ArgumentError,
264+
~r/expected :scrubber to be a \{module, function, args\} tuple/,
265+
fn ->
266+
Sentry.LiveViewHook.on_mount([scrubber: :not_a_tuple], %{}, %{}, %{})
267+
end
268+
end
269+
270+
test "logs error and uses empty data when scrubber raises", %{conn: conn} do
271+
{view, log} =
272+
ExUnit.CaptureLog.with_log(fn ->
273+
{:ok, view, _html} = live(conn, "/raising_scrubber")
274+
render_hook(view, :submit, %{"foo" => "bar"})
275+
view
276+
end)
277+
278+
assert log =~ "Sentry.LiveViewHook scrubber raised an error"
279+
280+
[event_breadcrumb | _] = get_sentry_context(view).breadcrumbs
281+
assert event_breadcrumb.category == "web.live_view.event"
282+
assert event_breadcrumb.data == %{}
283+
end
284+
285+
test "logs error and uses empty data when scrubber returns a non-map", %{conn: conn} do
286+
{view, log} =
287+
ExUnit.CaptureLog.with_log(fn ->
288+
{:ok, view, _html} = live(conn, "/non_map_scrubber")
289+
render_hook(view, :submit, %{"foo" => "bar"})
290+
view
291+
end)
292+
293+
assert log =~ "Sentry.LiveViewHook scrubber returned non-map value"
294+
295+
[event_breadcrumb | _] = get_sentry_context(view).breadcrumbs
296+
assert event_breadcrumb.category == "web.live_view.event"
297+
assert event_breadcrumb.data == %{}
298+
end
299+
300+
test "uses a user-supplied scrubber when configured", %{conn: conn} do
301+
{:ok, view, _html} = live(conn, "/custom_scrubber")
302+
303+
render_hook(view, :submit, %{
304+
"api_key" => "topsecret",
305+
"other" => "not-redacted"
306+
})
307+
308+
[event_breadcrumb | _] = get_sentry_context(view).breadcrumbs
309+
310+
assert event_breadcrumb.data == %{
311+
event: "submit",
312+
params: %{
313+
"api_key" => "*********",
314+
"other" => "not-redacted"
315+
}
316+
}
317+
end
318+
167319
defp get_sentry_context(view) do
168320
{:dictionary, pdict} = Process.info(view.pid, :dictionary)
169321

0 commit comments

Comments
 (0)