Phoenix’s filter_parameters only hides parameter values in the Parameters:
line. Phoenix.Logger
logs the raw request_path in the request line, at info by default:
%{method: method, request_path: request_path} = conn
[method, ?\s, request_path]So a magic link like /users/log-in/:token lands in production logs whole.
Anyone reading them before the link is used can log in as that user.
(I found this reading the source.)
I added a :logger primary filter
that rewrites the token segment to [FILTERED] in log lines and crash reports,
whichever library logged them.
:logger.add_primary_filter/2puts a new filter first, before Elixir’s translator (which turns crash reports into text), so crash text gets past it. Append it instead.:loggerremoves a filter that raises, for good, soscrubswaps an unreadable line for a placeholder.
lib/my_app_web/token_paths.ex
defmodule MyAppWeb.TokenPaths do
@token_paths ~w(invites users/log-in users/settings/confirm-email)
@token_in_path ~r{((?:^|/)(?:#{Enum.join(@token_paths, "|")})/)[^/?#\s]+}
def redact(text) when is_binary(text), do: Regex.replace(@token_in_path, text, "\\1[FILTERED]")
# Call from Application.start/2, before the supervision tree
def install_log_filter do
entry = {:token_paths, {&__MODULE__.filter_log/2, []}}
%{filters: filters} = :logger.get_primary_config()
:logger.set_primary_config(:filters, List.keystore(filters, :token_paths, 0, entry))
end
def filter_log(%{msg: {:string, text}} = event, _config), do: scrub(event, text, &{:string, &1})
# On Elixir 1.20 a translated crash is still a report; handlers print this text
def filter_log(%{msg: {:report, %{elixir_translation: text} = report}} = event, _config) do
scrub(event, text, &{:report, %{report | elixir_translation: &1}})
end
def filter_log(_event, _config), do: :ignore
defp scrub(event, text, msg) do
with {:ok, binary} <- to_binary(text), ^binary <- redact(binary) do
:ignore
else
:error -> %{event | msg: msg.("[FILTERED: unreadable log line]")}
redacted -> %{event | msg: msg.(redacted)}
end
end
defp to_binary(text) when is_binary(text), do: {:ok, text}
defp to_binary(text) do
case :unicode.characters_to_binary(text) do
binary when is_binary(binary) -> {:ok, binary}
_invalid -> :error
end
rescue
ArgumentError -> :error
end
endA test walks the router, so a new :token route fails until it’s on the list:
test "every route with a token in its path is redacted" do
paths =
for %{path: path} <- Phoenix.Router.routes(MyAppWeb.Router), path =~ ~r/:\w*token/, uniq: true do
Regex.replace(~r/:\w+/, path, "SECRET")
end
for path <- paths do
refute TokenPaths.redact(path) =~ "SECRET", "#{path} is missing from @token_paths"
end
endThe test matches the parameter name, so a new /share/:code route still leaks.
