Skip to content

Commit 7ce573c

Browse files
authored
Merge pull request #239 from ericmj/ericmj/credential-redaction
Extend credential redaction in connection diagnostics
2 parents bb7a12b + 8833892 commit 7ce573c

6 files changed

Lines changed: 582 additions & 49 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 1.18.0
44

5+
* **Behavior change:** `Gnat.start_link/2` now returns `{:error, reason}` when the TLS upgrade, the header support check or sending `CONNECT` fails, instead of crashing during initialization. TLS alerts come back as-is (for example `{:error, {:tls_alert, {:certificate_expired, _}}}`), so `Gnat.ConnectionSupervisor` logs why the connection failed. Invalid `ssl_opts` return `{:error, {:options, {option, :redacted}}}` because `:ssl` echoes the option value, which can be a key or password.
56
* **Behavior change:** `Gnat.Jetstream.API.KV.put_value/5`, `delete_key/4`, and `purge_key/4` now validate the JetStream publish acknowledgement instead of reporting `:ok` for any reply. They share the same ack validation as `create_key/5`: a rejected write returns `{:error, reason}` with the server's error map, and a malformed or unrelated ack returns `{:error, :invalid_publish_ack}`. Previously a server-side rejection (for example a `put_value/5` larger than the bucket's `max_value_size`) was silently reported as `:ok`. See #235
67
* **Behavior change:** `Gnat.Jetstream.API.KV.create_key/5` now only creates a key when one doesn't already exist. Previously it behaved like `put_value/5` and silently overwrote existing keys, and it returned `:ok` even when the server rejected the write. This aligns the library with the NATS server and the official clients: the publish is conditional on the key's current revision (`Nats-Expected-Last-Subject-Sequence`), so concurrent creators can't overwrite each other, and keys that were deleted or purged can be recreated. If you relied on `create_key` overwriting an existing key, use `put_value/5` instead. Thanks to @ericmj for #234
78
* Returns `{:error, :key_exists}` when a live value already exists for the key.

‎lib/gnat.ex‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@ defmodule Gnat do
6363
because the token can then appear in supervisor crash reports.
6464
6565
A function is called while the CONNECT message is built, during each connection handshake.
66-
The `nkey_seed`, `password` and `token` settings are redacted from the connection's process
67-
status, its crash reports and the connection supervisor's log.
66+
The `nkey_seed`, `password`, `token`, `jwt` and `ssl_opts` settings are redacted from
67+
formatted process status, crash reports and connection supervisor logs. See
68+
`start_link/2` for the limits of diagnostic redaction.
6869
"""
6970
@type connection_settings :: %{
7071
optional(:connection_timeout) => non_neg_integer(),
@@ -188,6 +189,13 @@ defmodule Gnat do
188189
The `:inbox_prefix` must be a binary without spaces, tabs, carriage returns or
189190
line feeds. Invalid values raise `ArgumentError` before connecting.
190191
192+
Credentials are redacted from connection configuration logs and formatted OTP
193+
status and crash reports. Exceptions raised during connection initialization
194+
report their class and stack locations, with details and arguments redacted.
195+
Raw debugging interfaces still have access to the original settings, including
196+
`:sys.get_state/1`, tracing, and the raw debug history returned by
197+
`:sys.get_status/1` when `:sys.log/2` is enabled.
198+
191199
The final `opts` argument will be passed to the `GenServer.start_link` call so you can pass things like `[name: :gnat_connection]`.
192200
"""
193201
@spec start_link(connection_settings(), keyword()) :: GenServer.on_start()
@@ -486,14 +494,8 @@ defmodule Gnat do
486494
GenServer.call(name, :server_info)
487495
end
488496

489-
# GenServer declares format_status/1 from Elixir 1.17; OTP calls it on every supported version.
490-
if Version.match?(System.version(), ">= 1.17.0"), do: @impl(GenServer)
491-
492-
def format_status(%{state: %{connection_settings: settings} = state} = status) do
493-
%{status | state: %{state | connection_settings: Gnat.Handshake.redact_settings(settings)}}
494-
end
495-
496-
def format_status(status), do: status
497+
@impl {:format_status, 1} in GenServer.behaviour_info(:callbacks)
498+
def format_status(status), do: Gnat.Redaction.redact(status)
497499

498500
@impl GenServer
499501
def init(connection_settings) do
@@ -526,6 +528,9 @@ defmodule Gnat do
526528
{:error, reason} ->
527529
{:stop, reason}
528530
end
531+
rescue
532+
exception ->
533+
Gnat.Redaction.reraise_connection_error(exception, __STACKTRACE__)
529534
end
530535

531536
@impl GenServer

‎lib/gnat/connection_supervisor.ex‎

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ defmodule Gnat.ConnectionSupervisor do
1919
}
2020
```
2121
22-
The connection settings can specify all of the same values that you pass to `Gnat.start_link/1`. Their secrets are redacted from this process's status and crash reports. Each time a connection is attempted we will use one of the provided connection settings to open the connection. This is a simplistic way of load balancing your connections across a cluster of nats nodes and allowing failover to other nodes in the cluster if one goes down.
22+
The connection settings can specify all of the same values that you pass to `Gnat.start_link/1`. Each time a connection is attempted we will use one of the provided connection settings to open the connection. This is a simplistic way of load balancing your connections across a cluster of nats nodes and allowing failover to other nodes in the cluster if one goes down.
23+
24+
Credentials are redacted from connection configuration logs and formatted OTP
25+
status and crash reports. The raw debugging limitations described in
26+
`Gnat.start_link/2` also apply to this process.
2327
2428
To use this in your supervision tree add an entry like this:
2529
@@ -41,6 +45,9 @@ defmodule Gnat.ConnectionSupervisor do
4145
GenServer.start_link(__MODULE__, settings, options)
4246
end
4347

48+
@impl {:format_status, 1} in GenServer.behaviour_info(:callbacks)
49+
def format_status(status), do: Gnat.Redaction.redact(status)
50+
4451
@impl GenServer
4552
def init(options) do
4653
state = %{
@@ -53,19 +60,22 @@ defmodule Gnat.ConnectionSupervisor do
5360
Process.flag(:trap_exit, true)
5461
send(self(), :attempt_connection)
5562
{:ok, state}
63+
rescue
64+
exception ->
65+
Gnat.Redaction.reraise_connection_error(exception, __STACKTRACE__)
5666
end
5767

5868
@impl GenServer
5969
def handle_info(:attempt_connection, state) do
6070
connection_config = random_connection_config(state)
61-
Logger.debug("connecting to #{inspect(Gnat.Handshake.redact_settings(connection_config))}")
71+
Logger.debug("connecting to #{inspect(Gnat.Redaction.redact(connection_config))}")
6272

6373
case Gnat.start_link(connection_config, name: state.name) do
6474
{:ok, gnat} ->
6575
{:noreply, %{state | gnat: gnat}}
6676

6777
{:error, err} ->
68-
Logger.error("failed to connect #{inspect(err)}")
78+
Logger.error("failed to connect #{inspect(Gnat.Redaction.redact(err))}")
6979
Process.send_after(self(), :attempt_connection, state.backoff_period)
7080
{:noreply, %{state | gnat: nil}}
7181
end
@@ -79,27 +89,19 @@ defmodule Gnat.ConnectionSupervisor do
7989
end
8090

8191
def handle_info({:EXIT, _pid, reason}, state) do
82-
Logger.error("connection failed #{inspect(reason)}")
92+
Logger.error("connection failed #{inspect(Gnat.Redaction.redact(reason))}")
8393
send(self(), :attempt_connection)
8494
{:noreply, state}
8595
end
8696

8797
def handle_info(msg, state) do
88-
Logger.error("#{__MODULE__} received unexpected message #{inspect(msg)}")
89-
{:noreply, state}
90-
end
98+
Logger.error(
99+
"#{__MODULE__} received unexpected message #{inspect(Gnat.Redaction.redact(msg))}"
100+
)
91101

92-
# GenServer declares format_status/1 from Elixir 1.17; OTP calls it on every supported version.
93-
if Version.match?(System.version(), ">= 1.17.0"), do: @impl(GenServer)
94-
95-
def format_status(%{state: %{connection_settings: settings} = state} = status)
96-
when is_list(settings) do
97-
redacted = Enum.map(settings, &Gnat.Handshake.redact_settings/1)
98-
%{status | state: %{state | connection_settings: redacted}}
102+
{:noreply, state}
99103
end
100104

101-
def format_status(status), do: status
102-
103105
defp random_connection_config(%{connection_settings: connection_settings}) do
104106
connection_settings |> Enum.random()
105107
end

‎lib/gnat/handshake.ex‎

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,6 @@ defmodule Gnat.Handshake do
1515
end
1616
end
1717

18-
@secret_settings [:nkey_seed, :password, :token]
19-
20-
@doc """
21-
Replaces the secrets in connection settings so they can be shown in process status and
22-
crash reports.
23-
"""
24-
def redact_settings(settings) when is_map(settings) do
25-
Enum.reduce(@secret_settings, settings, fn key, acc ->
26-
if Map.has_key?(acc, key), do: Map.put(acc, key, :redacted), else: acc
27-
end)
28-
end
29-
30-
def redact_settings(settings), do: settings
31-
3218
def negotiate_settings(server_settings, user_settings) do
3319
auth_required = server_settings[:auth_required] || user_settings[:auth_required] || false
3420

@@ -43,10 +29,15 @@ defmodule Gnat.Handshake do
4329
receive do
4430
{:tcp, ^tcp, operation} ->
4531
{_, [{:info, server_settings}]} = Parsec.parse(Parsec.new(), operation)
46-
{:ok, socket} = upgrade_connection(tcp, user_settings)
47-
settings = negotiate_settings(server_settings, user_settings)
48-
:ok = send_connect(user_settings, settings, socket)
49-
{:ok, socket, server_settings}
32+
33+
# Expected failures are returned rather than raised, because exceptions raised during
34+
# initialization are redacted and would hide the reason from operators.
35+
with :ok <- check_headers(server_settings, user_settings),
36+
{:ok, socket} <- upgrade_connection(tcp, user_settings),
37+
settings = negotiate_settings(server_settings, user_settings),
38+
:ok <- send_connect(user_settings, settings, socket) do
39+
{:ok, socket, server_settings}
40+
end
5041
after
5142
1000 ->
5243
{:error, "timed out waiting for info"}
@@ -114,10 +105,6 @@ defmodule Gnat.Handshake do
114105
end
115106
end
116107

117-
defp negotiate_headers(_settings, _server, %{headers: true} = _user) do
118-
raise "NATS Server does not support headers, but your connection settings specify header support"
119-
end
120-
121108
defp negotiate_headers(settings, _server, _user) do
122109
settings
123110
end
@@ -140,10 +127,33 @@ defmodule Gnat.Handshake do
140127
settings
141128
end
142129

130+
defp check_headers(%{headers: true} = _server, _user), do: :ok
131+
132+
defp check_headers(_server, %{headers: true} = _user) do
133+
{:error,
134+
"NATS Server does not support headers, but your connection settings specify header support"}
135+
end
136+
137+
defp check_headers(_server, _user), do: :ok
138+
143139
defp upgrade_connection(tcp, %{tls: true, ssl_opts: opts}) do
144140
:ok = :inet.setopts(tcp, active: true)
145-
:ssl.connect(tcp, opts, 1_000)
141+
142+
case :ssl.connect(tcp, opts, 1_000) do
143+
{:ok, socket} -> {:ok, socket}
144+
{:error, reason} -> {:error, tls_error(reason)}
145+
end
146146
end
147147

148148
defp upgrade_connection(tcp, _settings), do: {:ok, tcp}
149+
150+
# :ssl echoes the offending value in option errors, which can be a private key, password
151+
# or callback state, so only the option name is kept. Alerts such as an expired or untrusted
152+
# certificate are returned as-is.
153+
defp tls_error({:options, detail}) when is_tuple(detail) and is_atom(elem(detail, 0)) do
154+
{:options, {elem(detail, 0), :redacted}}
155+
end
156+
157+
defp tls_error({:options, _detail}), do: {:options, :redacted}
158+
defp tls_error(reason), do: reason
149159
end

‎lib/gnat/redaction.ex‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
defmodule Gnat.Redaction do
2+
@moduledoc false
3+
4+
# TLS options can contain private keys, passwords and callback state.
5+
@credential_keys [:password, :token, :jwt, :nkey_seed, :ssl_opts]
6+
7+
def redact(term) when is_map(term) do
8+
:maps.map(fn key, value -> redact_value(key, value) end, term)
9+
end
10+
11+
def redact({key, _value}) when key in @credential_keys, do: {key, :redacted}
12+
13+
def redact(term) when is_tuple(term) do
14+
term |> Tuple.to_list() |> redact() |> List.to_tuple()
15+
end
16+
17+
def redact([head | tail]), do: [redact(head) | redact(tail)]
18+
def redact(term), do: term
19+
20+
def reraise_connection_error(exception, stacktrace) do
21+
message = "connection initialization raised #{inspect(exception.__struct__)}: [REDACTED]"
22+
reraise RuntimeError, [message: message], redact_stacktrace(stacktrace)
23+
end
24+
25+
defp redact_stacktrace(stacktrace) do
26+
Enum.map(stacktrace, fn {module, function, args_or_arity, location} ->
27+
arity = if is_list(args_or_arity), do: length(args_or_arity), else: args_or_arity
28+
{module, function, arity, Keyword.take(location, [:file, :line])}
29+
end)
30+
end
31+
32+
defp redact_value(key, _value) when key in @credential_keys, do: :redacted
33+
defp redact_value(_key, value), do: redact(value)
34+
end

0 commit comments

Comments
 (0)