Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 88 additions & 8 deletions lib/sentry/live_view_hook.ex
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,28 @@ if Code.ensure_loaded?(Phoenix.LiveView) do

You can also set this in your `MyAppWeb` module, so that all LiveViews that
`use MyAppWeb, :live_view` will have this hook.

## Scrubbing Sensitive Data

*Available since v13.1.0.*

LiveView events and `handle_params` calls frequently carry user-submitted
form data, which may include passwords or other sensitive values. Before
storing this data in breadcrumbs, this hook scrubs it using
`Sentry.Scrubber.scrub_map/2`. URI query strings stored in breadcrumbs are
scrubbed via `Sentry.Scrubber.scrub_url/2`.

To customize the scrubbing logic, pass a `:scrubber` option when attaching
the hook. The scrubber must be a `{module, function, args}` tuple; the
breadcrumb `data` map is prepended to `args` before invoking the function,
which must return a map.

on_mount {Sentry.LiveViewHook, scrubber: {MyApp.Scrubber, :scrub, []}}

The default scrubber is equivalent to:

{Sentry.LiveViewHook, :default_scrubber, []}

"""

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

require Logger

@scrubber_pdict_key {__MODULE__, :scrubber}

# See also:
# https://develop.sentry.dev/sdk/event-payloads/request/

@doc false
@spec on_mount(:default, map() | :not_mounted_at_router, map(), struct()) :: {:cont, struct()}
def on_mount(:default, %{} = params, _session, socket), do: on_mount(params, socket)
def on_mount(:default, :not_mounted_at_router, _session, socket), do: {:cont, socket}
@spec on_mount(:default | keyword(), map() | :not_mounted_at_router, map(), struct()) ::
{:cont, struct()}
def on_mount(:default, params, session, socket),
do: on_mount([], params, session, socket)

def on_mount(opts, %{} = params, _session, socket) when is_list(opts) do
store_scrubber(opts)
on_mount(params, socket)
end

def on_mount(opts, :not_mounted_at_router, _session, socket) when is_list(opts) do
store_scrubber(opts)
{:cont, socket}
end

@doc """
The default scrubber applied to LiveView breadcrumb data.

Delegates to `Sentry.Scrubber.scrub_map/2` with the default sensitive
parameter keys.
"""
@doc since: "13.1.0"
@spec default_scrubber(map()) :: map()
def default_scrubber(data) when is_map(data) do
Sentry.Scrubber.scrub_map(data)
end

## Helpers

defp store_scrubber(opts) do
case Keyword.get(opts, :scrubber, {__MODULE__, :default_scrubber, []}) do
{mod, fun, args} = scrubber when is_atom(mod) and is_atom(fun) and is_list(args) ->
Process.put(@scrubber_pdict_key, scrubber)

other ->
raise ArgumentError,
"expected :scrubber to be a {module, function, args} tuple, got: #{inspect(other)}"
end
end

defp scrub(data) when is_map(data) do
{mod, fun, args} =
Process.get(@scrubber_pdict_key, {__MODULE__, :default_scrubber, []})

case apply(mod, fun, [data | args]) do
result when is_map(result) ->
result

other ->
Logger.error(
"Sentry.LiveViewHook scrubber returned non-map value: #{inspect(other)}; " <>
"falling back to redacted data",
event_source: :logger
)

%{}
end
end

defp scrub_uri(uri) when is_binary(uri), do: Sentry.Scrubber.scrub_url(uri)

defp on_mount(params, %Phoenix.LiveView.Socket{} = socket) do
Context.set_extra_context(%{socket_id: socket.id})
Context.set_request_context(%{url: socket.host_uri})

Context.add_breadcrumb(%{
category: "web.live_view.mount",
message: "Mounted live view",
data: params
data: scrub(params)
})

if uri = get_connect_info_if_root(socket, :uri) do
Expand Down Expand Up @@ -105,7 +184,7 @@ if Code.ensure_loaded?(Phoenix.LiveView) do
Context.add_breadcrumb(%{
category: "web.live_view.event",
message: inspect(event),
data: %{event: event, params: params}
data: scrub(%{event: event, params: params})
})

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

defp handle_params_hook(params, uri, socket) do
scrubbed_uri = scrub_uri(uri)
Context.set_extra_context(%{socket_id: socket.id})
Context.set_request_context(%{url: uri})
Context.set_request_context(%{url: scrubbed_uri})

Context.add_breadcrumb(%{
category: "web.live_view.params",
message: "#{uri}",
data: %{params: params, uri: uri}
message: "#{scrubbed_uri}",
data: scrub(%{params: params, uri: scrubbed_uri})
})

{:cont, socket}
Expand Down
70 changes: 69 additions & 1 deletion test/sentry/live_view_hook_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ defmodule SentryTest.Live do
{:ok, socket}
end

def handle_event("refresh", _params, socket) do
def handle_event(_event, _params, socket) do
{:noreply, socket}
end

Expand All @@ -23,6 +23,22 @@ defmodule SentryTest.Live do
end
end

defmodule SentryTest.CustomScrubber do
def scrub(data), do: Sentry.Scrubber.scrub_map(data, keys: ["api_key"])
end

defmodule SentryTest.CustomScrubberLive do
use Phoenix.LiveView

on_mount {Sentry.LiveViewHook, scrubber: {SentryTest.CustomScrubber, :scrub, []}}

def render(assigns), do: ~H"<h1>custom</h1>"

def mount(_params, _session, socket), do: {:ok, socket}

def handle_event(_event, _params, socket), do: {:noreply, socket}
end

defmodule SentryTest.LiveComponent do
use Phoenix.LiveComponent

Expand Down Expand Up @@ -66,6 +82,7 @@ defmodule SentryTest.Router do
scope "/" do
get "/dead_test", SentryTest.PageController, :page
live "/hook_test", SentryTest.Live
live "/custom_scrubber", SentryTest.CustomScrubberLive
end
end

Expand Down Expand Up @@ -164,6 +181,57 @@ defmodule Sentry.LiveViewHookTest do
assert Logger.metadata() == []
end

test "scrubs sensitive data from breadcrumbs by default", %{conn: conn} do
{:ok, view, _html} = live(conn, "/hook_test")

render_hook(view, :login, %{
"email" => "user@example.com",
"password" => "supersecret",
"card" => "4111111111111111"
})

[event_breadcrumb | _] = get_sentry_context(view).breadcrumbs

assert event_breadcrumb.data == %{
event: "login",
params: %{
"email" => "user@example.com",
"password" => "*********",
"card" => "*********"
}
}
end

test "scrubs sensitive query params from URI in handle_params breadcrumb", %{conn: conn} do
{:ok, view, _html} = live(conn, "/hook_test?password=supersecret&visible=ok")

breadcrumbs = get_sentry_context(view).breadcrumbs
params_breadcrumb = Enum.find(breadcrumbs, &(&1.category == "web.live_view.params"))

refute params_breadcrumb.data.uri =~ "supersecret"
assert params_breadcrumb.data.uri =~ "password=%2A%2A%2A%2A%2A%2A%2A%2A%2A"
assert params_breadcrumb.data.uri =~ "visible=ok"
end

test "uses a user-supplied scrubber when configured", %{conn: conn} do
{:ok, view, _html} = live(conn, "/custom_scrubber")

render_hook(view, :submit, %{
"api_key" => "topsecret",
"other" => "not-redacted"
})

[event_breadcrumb | _] = get_sentry_context(view).breadcrumbs

assert event_breadcrumb.data == %{
event: "submit",
params: %{
"api_key" => "*********",
"other" => "not-redacted"
}
}
end

defp get_sentry_context(view) do
{:dictionary, pdict} = Process.info(view.pid, :dictionary)

Expand Down
Loading