Skip to content

Protecting sensitive data in Elixir GenServers

Posted on:November 27, 2023

In Elixir, GenServers are a common way to maintain state and handle concurrent processes. However, when these GenServers hold sensitive data, such as credentials or personal information, it’s crucial to ensure this data is protected. Sensitive data, if exposed, can lead to serious security breaches, including data leaks and unauthorized access. These breaches can have far-reaching consequences, such as loss of customer trust, damage to your brand’s reputation, and potential legal liabilities.

In this blog post, we’ll explore two techniques to protect sensitive data in Elixir GenServers: implementing the Inspect protocol for structs and implementing the format_status/2 callback.

To illustrate this, let’s take a look at a GenServer that is handling some sensitive data. (I ended up writing a GenServer quite long for a blog post. However, I hope that this example can help to understand the different ways of hiding sensitive data in a GenServer, and the trade-offs involved in each approach)

defmodule SecurityTokenManager do
  use GenServer

  defstruct [:access_key, :secret_access, :security_token, :expires_at]

  @security_token_size 20
  @milliseconds_in_second 1000
  @expires_in_seconds 60 * 15 # 15 minutes

  def start_link do
    GenServer.start_link(
      __MODULE__,
      %{
        access_key: System.get_env("ACCESS_KEY"),
        secret_access: System.get_env("SECRET_ACCESS")
      },
      name: __MODULE__
    )
  end

  def get_security_token do
    GenServer.call(__MODULE__, :get_security_token)
  end

  def handle_call(:get_security_token, _from, state) do
    {:reply, state.security_token, state}
  end

  def handle_info(:refresh_token, state) do
    {security_token, expires_at} =
      generate_security_token(state.access_key, state.secret_access)

    schedule_refresh_token(expires_at)

    new_state = %{state | security_token: security_token, expires_at: expires_at}
    {:noreply, new_state}
  end

  def init(args) do
    schedule_refresh_token(DateTime.utc_now())

    {:ok, %__MODULE__{access_key: args[:access_key], secret_access: args[:secret_access]}}
  end

  # This function simulates an authentication process where `access_key` and `secret_access` are provided
  # to a downstream service, which in turn returns a `security_token` along with its `expires_at timestamp.
  defp generate_security_token(_access_key, _secret_access) do
    {security_token(), expires_at()}
  end

  defp schedule_refresh_token(expires_at) do
    current_time = DateTime.utc_now()
    time_difference = DateTime.diff(expires_at, current_time)

    # Send a `:refresh_token message after the time difference in seconds
    Process.send_after(self(), :refresh_token, time_difference * @milliseconds_in_second)
  end

  # generates a random token for demonstration purposes.
  defp security_token do
    :crypto.strong_rand_bytes(@security_token_size) |> Base.encode64()
  end

  defp expires_at do
    DateTime.utc_now() |> DateTime.add(@expires_in_seconds)
  end
end

Basically this GenServer acts like a diligent security guard managing a special “security token” that expires every 15 minutes. It doesn’t wait for the token to expire, but proactively starts a countdown to refresh the token just before expiration. When another process requests the token via the get_security_token function, it ensures the token is valid before handing it over. This creates a seamless cycle of token issuance, countdown, and renewal, ensuring a valid token is always available.

 iex security_token_manager.ex
Erlang/OTP 26 [erts-14.1] [source] [64-bit] [smp:10:10] [ds:10:10:10] [async-threads:1] [jit]

Interactive Elixir (1.15.6) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> {:ok, pid} = SecurityTokenManager.start_link()
{:ok, #PID<0.116.0>}
iex(2)> SecurityTokenManager.get_security_token()
"8QVrN1ohPPdiWHfnmEr+ln4VQ4Y="

While it effectively manages the lifecycle of security tokens, it does have a potential security concern. The GenServer stores sensitive data, such as the access_key, secret_access, and security_token, in its state. This data could potentially be leaked through logging tools when some error is raised for example.

Error log output from the SecurityTokenManager GenServer. The server is terminating due to a RuntimeError that occurred while trying to fetch a security token. The error details, including the function calls leading to the error and the state that reveals sensitive data, are displayed.

Or via the :sys.get_status/1 function, which can access the state of a running process.

iex(3)> :sys.get_status(pid)
{:status, #PID<0.116.0>, {:module, :gen_server},
 [
   [
     "$initial_call": {SecurityTokenManager, :init, 1},
     "$ancestors": [#PID<0.115.0>, #PID<0.107.0>]
   ],
   :running,
   #PID<0.115.0>,
   [],
   [
     header: ~c"Status for generic server Elixir.SecurityTokenManager",
     data: [
       {~c"Status", :running},
       {~c"Parent", #PID<0.115.0>},
       {~c"Logged events", []}
     ],
     data: [
       {~c"State",
        %SecurityTokenManager{
          access_key: "my-access-key",
          secret_access: "my-secret-access",
          security_token: "ZjhkWWzemgvCMZXwIit+a/00FHw=",
          expires_at: ~U[2023-11-26 15:05:49.494781Z]
        }}
     ]
   ]
 ]}

This could lead to unauthorized access if the leaked information falls into the wrong hands. Therefore, it’s crucial to ensure that sensitive data stored in a GenServer’s state is adequately protected.

Implementing the format_status/2 callback

The format_status/2 callback provides a way to protect sensitive data in GenServers. This callback is used to provide a custom representation of the GenServer’s state when debugging or introspecting the process.

By default, the format_status/2 callback returns all the state data. To protect sensitive data, we can implement this callback to filter out or obfuscate the sensitive parts of the state.

Here’s how we can implement the format_status/2 callback in our GenServer:

def format_status(_reason, [pdict, state]) do
  {:ok,
    [
      pdict,
      %{
        state
        | access_key: "<sensitive_data>",
          secret_access: "<sensitive_data>",
          security_token: "<sensitive_data>"
      }
    ]}
end

So, when the :sys.get_status/1 is called we’ll have a response that does not display any sensitive data.

iex(4)> :sys.get_status(pid)
{:status, #PID<0.116.0>, {:module, :gen_server},
 [
   [
     "$initial_call": {SecurityTokenManager, :init, 1},
     "$ancestors": [#PID<0.115.0>, #PID<0.107.0>]
   ],
   :running,
   #PID<0.115.0>,
   [],
   [
     header: ~c"Status for generic server Elixir.SecurityTokenManager",
     data: [
       {~c"Status", :running},
       {~c"Parent", #PID<0.115.0>},
       {~c"Logged events", []}
     ],
     ok: [
       [
         "$initial_call": {SecurityTokenManager, :init, 1},
         "$ancestors": [#PID<0.115.0>, #PID<0.107.0>]
       ],
       %SecurityTokenManager{
         access_key: "<sensitive_data>",
         secret_access: "<sensitive_data>",
         security_token: "<sensitive_data>",
         expires_at: ~U[2023-11-26 16:59:47.764327Z]
       }
     ]
   ]
 ]}

This is certainly an improvement, isn’t it? However, one concern that arises is that sensitive data can still be accessed via the :sys.get_state/1 function, even with the implementation of format_status/2.

iex(5)> :sys.get_state(pid)
%SecurityTokenManager{
  access_key: "my-access-key",
  secret_access: "my-secret-access",
  security_token: "fZWO+Dym+bEJ9kw8E1nLNryT5m0=",
  expires_at: ~U[2023-11-26 17:16:47.936304Z]
}

The next section will delve into how to prevent this issue.

Implementing or deriving the Inspect protocol for structs

The Inspect protocol controls how data structures are converted to strings for printing. By default, when a struct is printed, all of its data is exposed. So, again this can lead to sensitive data being accidentally logged or displayed. To prevent this, we can implement the Inspect protocol for our struct to control how it is printed.

defimpl Inspect, for: SecurityTokenManager do
  def inspect(%SecurityTokenManager{} = state, opts) do
    Inspect.Map.inspect(
      %{
        access_key: "<redacted>",
        secret_access: "<redacted>",
        security_token: "<redacted>",
        expires_at: state.expires_at
      },
      opts
    )
  end
end

With the implementation of the Inspect protocol now established, we can achieve the same structured output for both :sys.get_state/1 and :sys.get_status/1 functions.

iex(6)> :sys.get_state(pid)
%{
  access_key: "<redacted>",
  secret_access: "<redacted>",
  security_token: "<redacted>",
  expires_at: ~U[2023-11-26 21:37:53.396092Z]
}
iex(7)> :sys.get_status(pid)
{:status, #PID<0.119.0>, {:module, :gen_server},
 [
   [
     "$initial_call": {SecurityTokenManager, :init, 1},
     "$ancestors": [#PID<0.118.0>, #PID<0.110.0>]
   ],
   :running,
   #PID<0.118.0>,
   [],
   [
     header: ~c"Status for generic server Elixir.SecurityTokenManager",
     data: [
       {~c"Status", :running},
       {~c"Parent", #PID<0.118.0>},
       {~c"Logged events", []}
     ],
     ok: [
       [
         "$initial_call": {SecurityTokenManager, :init, 1},
         "$ancestors": [#PID<0.118.0>, #PID<0.110.0>]
       ],
       %{
         access_key: "<redacted>",
         secret_access: "<redacted>",
         security_token: "<redacted>",
         expires_at: ~U[2023-11-26 21:37:53.396092Z]
       }
     ]
   ]
 ]}

As stated in the subtitle, an alternative method involves deriving the Inspect protocol. The :only and :except options can be utilized with @derive to determine which fields should be displayed and which should not. For simplicity, we’ll use the :only option in this instance.

@derive {Inspect, only: [:expires_at]}
defstruct [:access_key, :secret_access, :security_token, :expires_at]

In this method, only the :expires_at will be visible. The rest of the fields will not just have their values hidden, but their keys will be completely omitted as well.

iex(8)> :sys.get_state(pid)
#SecurityTokenManager<expires_at: ~U[2023-11-26 22:42:56.998354Z], ...>
iex(9)> :sys.get_status(pid)
{:status, #PID<0.119.0>, {:module, :gen_server},
 [
   [
     "$initial_call": {SecurityTokenManager, :init, 1},
     "$ancestors": [#PID<0.118.0>, #PID<0.110.0>]
   ],
   :running,
   #PID<0.118.0>,
   [],
   [
     header: ~c"Status for generic server Elixir.SecurityTokenManager",
     data: [
       {~c"Status", :running},
       {~c"Parent", #PID<0.118.0>},
       {~c"Logged events", []}
     ],
     data: [
       {~c"State",
        #SecurityTokenManager<expires_at: ~U[2023-11-26 22:43:57.000550Z], ...>}
     ]
   ]
 ]}

Conclusion

This blog post has explored some techniques to protect sensitive data in Elixir GenServers. It has shown how to implement or derive the Inspect protocol for structs, and how to implement the format_status/2 callback for GenServer, :gen_event or :gen_statem processes holding sensitive data. These techniques can help prevent or limit the exposure of sensitive data in logs, error reports, or terminal outputs, which can compromise the security and privacy of the application and its users.

I hope you have found this useful and informative, and I encourage you to try these techniques in your own projects. If you have any questions or feedback, please feel free to leave a comment below.

References