Skip to content

Commit 32c9ab8

Browse files
solnicclaude
andcommitted
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>
1 parent c9388da commit 32c9ab8

2 files changed

Lines changed: 157 additions & 9 deletions

File tree

lib/sentry/live_view_hook.ex

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,28 @@ 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+
4264
"""
4365

4466
@moduledoc since: "10.5.0"
@@ -49,24 +71,81 @@ if Code.ensure_loaded?(Phoenix.LiveView) do
4971

5072
require Logger
5173

74+
@scrubber_pdict_key {__MODULE__, :scrubber}
75+
5276
# See also:
5377
# https://develop.sentry.dev/sdk/event-payloads/request/
5478

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

60107
## Helpers
61108

109+
defp store_scrubber(opts) do
110+
case Keyword.get(opts, :scrubber, {__MODULE__, :default_scrubber, []}) do
111+
{mod, fun, args} = scrubber when is_atom(mod) and is_atom(fun) and is_list(args) ->
112+
Process.put(@scrubber_pdict_key, scrubber)
113+
114+
other ->
115+
raise ArgumentError,
116+
"expected :scrubber to be a {module, function, args} tuple, got: #{inspect(other)}"
117+
end
118+
end
119+
120+
defp scrub(data) when is_map(data) do
121+
{mod, fun, args} =
122+
Process.get(@scrubber_pdict_key, {__MODULE__, :default_scrubber, []})
123+
124+
case apply(mod, fun, [data | args]) do
125+
result when is_map(result) ->
126+
result
127+
128+
other ->
129+
Logger.error(
130+
"Sentry.LiveViewHook scrubber returned non-map value: #{inspect(other)}; " <>
131+
"falling back to redacted data",
132+
event_source: :logger
133+
)
134+
135+
%{}
136+
end
137+
end
138+
139+
defp scrub_uri(uri) when is_binary(uri), do: Sentry.Scrubber.scrub_url(uri)
140+
62141
defp on_mount(params, %Phoenix.LiveView.Socket{} = socket) do
63142
Context.set_extra_context(%{socket_id: socket.id})
64143
Context.set_request_context(%{url: socket.host_uri})
65144

66145
Context.add_breadcrumb(%{
67146
category: "web.live_view.mount",
68147
message: "Mounted live view",
69-
data: params
148+
data: scrub(params)
70149
})
71150

72151
if uri = get_connect_info_if_root(socket, :uri) do
@@ -105,7 +184,7 @@ if Code.ensure_loaded?(Phoenix.LiveView) do
105184
Context.add_breadcrumb(%{
106185
category: "web.live_view.event",
107186
message: inspect(event),
108-
data: %{event: event, params: params}
187+
data: scrub(%{event: event, params: params})
109188
})
110189

111190
{:cont, socket}
@@ -121,13 +200,14 @@ if Code.ensure_loaded?(Phoenix.LiveView) do
121200
end
122201

123202
defp handle_params_hook(params, uri, socket) do
203+
scrubbed_uri = scrub_uri(uri)
124204
Context.set_extra_context(%{socket_id: socket.id})
125-
Context.set_request_context(%{url: uri})
205+
Context.set_request_context(%{url: scrubbed_uri})
126206

127207
Context.add_breadcrumb(%{
128208
category: "web.live_view.params",
129-
message: "#{uri}",
130-
data: %{params: params, uri: uri}
209+
message: "#{scrubbed_uri}",
210+
data: scrub(%{params: params, uri: scrubbed_uri})
131211
})
132212

133213
{:cont, socket}

test/sentry/live_view_hook_test.exs

Lines changed: 69 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,22 @@ 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+
2642
defmodule SentryTest.LiveComponent do
2743
use Phoenix.LiveComponent
2844

@@ -66,6 +82,7 @@ defmodule SentryTest.Router do
6682
scope "/" do
6783
get "/dead_test", SentryTest.PageController, :page
6884
live "/hook_test", SentryTest.Live
85+
live "/custom_scrubber", SentryTest.CustomScrubberLive
6986
end
7087
end
7188

@@ -164,6 +181,57 @@ defmodule Sentry.LiveViewHookTest do
164181
assert Logger.metadata() == []
165182
end
166183

184+
test "scrubs sensitive data from breadcrumbs by default", %{conn: conn} do
185+
{:ok, view, _html} = live(conn, "/hook_test")
186+
187+
render_hook(view, :login, %{
188+
"email" => "user@example.com",
189+
"password" => "supersecret",
190+
"card" => "4111111111111111"
191+
})
192+
193+
[event_breadcrumb | _] = get_sentry_context(view).breadcrumbs
194+
195+
assert event_breadcrumb.data == %{
196+
event: "login",
197+
params: %{
198+
"email" => "user@example.com",
199+
"password" => "*********",
200+
"card" => "*********"
201+
}
202+
}
203+
end
204+
205+
test "scrubs sensitive query params from URI in handle_params breadcrumb", %{conn: conn} do
206+
{:ok, view, _html} = live(conn, "/hook_test?password=supersecret&visible=ok")
207+
208+
breadcrumbs = get_sentry_context(view).breadcrumbs
209+
params_breadcrumb = Enum.find(breadcrumbs, &(&1.category == "web.live_view.params"))
210+
211+
refute params_breadcrumb.data.uri =~ "supersecret"
212+
assert params_breadcrumb.data.uri =~ "password=%2A%2A%2A%2A%2A%2A%2A%2A%2A"
213+
assert params_breadcrumb.data.uri =~ "visible=ok"
214+
end
215+
216+
test "uses a user-supplied scrubber when configured", %{conn: conn} do
217+
{:ok, view, _html} = live(conn, "/custom_scrubber")
218+
219+
render_hook(view, :submit, %{
220+
"api_key" => "topsecret",
221+
"other" => "not-redacted"
222+
})
223+
224+
[event_breadcrumb | _] = get_sentry_context(view).breadcrumbs
225+
226+
assert event_breadcrumb.data == %{
227+
event: "submit",
228+
params: %{
229+
"api_key" => "*********",
230+
"other" => "not-redacted"
231+
}
232+
}
233+
end
234+
167235
defp get_sentry_context(view) do
168236
{:dictionary, pdict} = Process.info(view.pid, :dictionary)
169237

0 commit comments

Comments
 (0)