From f43a0303637b1aa22c9fc64d720e37583944aff5 Mon Sep 17 00:00:00 2001 From: Chris Kempton Date: Tue, 22 Sep 2026 14:49:27 -0400 Subject: [PATCH] feat: add Tesla.SecretString to keep secrets out of inspect output Middleware options live in the client, and every %Tesla.Env{} embeds its client, so a token given to a middleware printed wherever a client or env was inspected. Rather than redacting the client's Inspect output, make secrecy explicit on the value: Tesla.SecretString is redacted by Inspect and returns its value through String.Chars. Tesla.Middleware.Headers unwraps secret values when it puts them on the request; BearerAuth and BasicAuth accept them through interpolation. Co-Authored-By: Claude Opus 5.5 --- guides/explanations/0.client.md | 25 ++++++++++ lib/tesla/middleware/basic_auth.ex | 3 +- lib/tesla/middleware/bearer_auth.ex | 3 +- lib/tesla/middleware/headers.ex | 19 +++++++- lib/tesla/secret_string.ex | 57 ++++++++++++++++++++++ test/tesla/middleware/basic_auth_test.exs | 7 +++ test/tesla/middleware/bearer_auth_test.exs | 5 ++ test/tesla/middleware/header_test.exs | 8 +++ test/tesla/secret_string_test.exs | 33 +++++++++++++ 9 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 lib/tesla/secret_string.ex create mode 100644 test/tesla/secret_string_test.exs diff --git a/guides/explanations/0.client.md b/guides/explanations/0.client.md index f1d45758..27de7063 100644 --- a/guides/explanations/0.client.md +++ b/guides/explanations/0.client.md @@ -159,3 +159,28 @@ depends on your specific needs: Understanding these patterns helps you design applications and libraries that are flexible and maintainable, aligning with best practices in software development. + +## Secrets in middleware options + +Middleware options live in the client for as long as it exists, and every +`%Tesla.Env{}` embeds its client as `__client__`. A token handed to a +middleware would therefore print in any `inspect(client)` or `inspect(env)`, +including the common `Logger.error("request failed: #{inspect(env)}")`. + +Wrap such values in `Tesla.SecretString` to keep them out of that output: + +```elixir +client = + Tesla.client([ + {Tesla.Middleware.Headers, [{"authorization", Tesla.SecretString.new("Bearer #{token}")}]} + ]) + +inspect(client) +# ... {Tesla.Middleware.Headers, :call, [[{"authorization", #Tesla.SecretString}]]} ... +``` + +`Tesla.Middleware.Headers`, `Tesla.Middleware.BearerAuth` and +`Tesla.Middleware.BasicAuth` accept wrapped values and put the real value on +the request. `Tesla.SecretString` implements `String.Chars`, so middleware +that builds a value by interpolation, such as `"Bearer #{token}"`, works with +it as is. diff --git a/lib/tesla/middleware/basic_auth.ex b/lib/tesla/middleware/basic_auth.ex index ed2ae37f..d7defe70 100644 --- a/lib/tesla/middleware/basic_auth.ex +++ b/lib/tesla/middleware/basic_auth.ex @@ -20,7 +20,8 @@ defmodule Tesla.Middleware.BasicAuth do ## Options - `:username` - username (defaults to `""`) - - `:password` - password (defaults to `""`) + - `:password` - password (defaults to `""`). Wrap it in `Tesla.SecretString` to keep + it out of `inspect/1` output of the client. """ @behaviour Tesla.Middleware diff --git a/lib/tesla/middleware/bearer_auth.ex b/lib/tesla/middleware/bearer_auth.ex index 758ad196..dc78493e 100644 --- a/lib/tesla/middleware/bearer_auth.ex +++ b/lib/tesla/middleware/bearer_auth.ex @@ -18,7 +18,8 @@ defmodule Tesla.Middleware.BearerAuth do ## Options - - `:token` - token (defaults to `""`) + - `:token` - token (defaults to `""`). Wrap it in `Tesla.SecretString` to keep it + out of `inspect/1` output of the client. """ @behaviour Tesla.Middleware diff --git a/lib/tesla/middleware/headers.ex b/lib/tesla/middleware/headers.ex index 773e97dc..af8e1295 100644 --- a/lib/tesla/middleware/headers.ex +++ b/lib/tesla/middleware/headers.ex @@ -1,5 +1,5 @@ defmodule Tesla.Middleware.Headers do - @moduledoc """ + @moduledoc ~S""" Set default headers for all requests ## Examples @@ -13,6 +13,18 @@ defmodule Tesla.Middleware.Headers do end end ``` + + ## Secret header values + + Header values given here are stored in the client, so wrap sensitive ones + in `Tesla.SecretString` to keep them out of `inspect/1` output. The value + is unwrapped when it is put on the request. + + ```elixir + Tesla.client([ + {Tesla.Middleware.Headers, [{"authorization", Tesla.SecretString.new("Bearer #{token}")}]} + ]) + ``` """ @behaviour Tesla.Middleware @@ -20,7 +32,10 @@ defmodule Tesla.Middleware.Headers do @impl Tesla.Middleware def call(env, next, headers) do env - |> Tesla.put_headers(headers) + |> Tesla.put_headers(Enum.map(headers, &reveal/1)) |> Tesla.run(next) end + + defp reveal({name, %Tesla.SecretString{} = value}), do: {name, to_string(value)} + defp reveal(header), do: header end diff --git a/lib/tesla/secret_string.ex b/lib/tesla/secret_string.ex new file mode 100644 index 00000000..22138caf --- /dev/null +++ b/lib/tesla/secret_string.ex @@ -0,0 +1,57 @@ +defmodule Tesla.SecretString do + @moduledoc ~S""" + A string that is redacted when inspected. + + Middleware options live in the client for as long as it exists, and every + `%Tesla.Env{}` embeds its client, so a token given to a middleware would + otherwise print wherever a client or env is inspected, including a + `Logger.error("request failed: #{inspect(env)}")`. Wrapping the value keeps + the redaction with the value itself, however the surrounding structure is + inspected: + + iex> secret = Tesla.SecretString.new("Bearer s3cret") + iex> inspect(secret) + "#Tesla.SecretString" + iex> inspect([{"authorization", secret}]) + ~s([{"authorization", #Tesla.SecretString}]) + iex> to_string(secret) + "Bearer s3cret" + + `String.Chars` returns the value, so middleware that builds a header by + interpolation works with a wrapped value unchanged. The flip side is that + `"#{secret}"` prints it: interpolate a secret where it goes on the request, + never into a log message. + + ## Examples + + ```elixir + Tesla.client([ + {Tesla.Middleware.Headers, [{"authorization", Tesla.SecretString.new("Bearer #{token}")}]} + ]) + + Tesla.client([ + {Tesla.Middleware.BearerAuth, token: Tesla.SecretString.new(token)} + ]) + + Tesla.client([ + {Tesla.Middleware.BasicAuth, %{username: username, password: Tesla.SecretString.new(password)}} + ]) + ``` + """ + + @opaque t :: %__MODULE__{value: String.t()} + + defstruct [:value] + + @doc "Wraps `value`." + @spec new(String.t()) :: t() + def new(value) when is_binary(value), do: %__MODULE__{value: value} + + defimpl Inspect do + def inspect(_secret, _opts), do: "#Tesla.SecretString" + end + + defimpl String.Chars do + def to_string(secret), do: secret.value + end +end diff --git a/test/tesla/middleware/basic_auth_test.exs b/test/tesla/middleware/basic_auth_test.exs index 84e1276f..9dd38a27 100644 --- a/test/tesla/middleware/basic_auth_test.exs +++ b/test/tesla/middleware/basic_auth_test.exs @@ -76,4 +76,11 @@ defmodule Tesla.Middleware.BasicAuthTest do assert auth_header == "Basic #{base_64_encoded}" end + + test "accepts a Tesla.SecretString password" do + opts = %{username: "u", password: Tesla.SecretString.new("s3cret")} + + assert {:ok, env} = Tesla.Middleware.BasicAuth.call(%Tesla.Env{}, [], opts) + assert Tesla.get_header(env, "authorization") == "Basic " <> Base.encode64("u:s3cret") + end end diff --git a/test/tesla/middleware/bearer_auth_test.exs b/test/tesla/middleware/bearer_auth_test.exs index d6424675..0ec9c4c6 100644 --- a/test/tesla/middleware/bearer_auth_test.exs +++ b/test/tesla/middleware/bearer_auth_test.exs @@ -11,4 +11,9 @@ defmodule Tesla.Middleware.BearerAuthTest do assert {:ok, env} = @middleware.call(%Env{}, [], token: "token") assert env.headers == [{"authorization", "Bearer token"}] end + + test "accepts a Tesla.SecretString token" do + assert {:ok, env} = @middleware.call(%Env{}, [], token: Tesla.SecretString.new("token")) + assert env.headers == [{"authorization", "Bearer token"}] + end end diff --git a/test/tesla/middleware/header_test.exs b/test/tesla/middleware/header_test.exs index 1be9efc3..f5b44cda 100644 --- a/test/tesla/middleware/header_test.exs +++ b/test/tesla/middleware/header_test.exs @@ -12,4 +12,12 @@ defmodule Tesla.Middleware.HeadersTest do assert env.headers == [{"authorization", "secret"}, {"content-type", "text/plain"}] end + + test "puts the value of a Tesla.SecretString on the request" do + headers = [{"authorization", Tesla.SecretString.new("secret")}, {"user-agent", "Tesla"}] + + assert {:ok, env} = @middleware.call(%Env{}, [], headers) + + assert env.headers == [{"authorization", "secret"}, {"user-agent", "Tesla"}] + end end diff --git a/test/tesla/secret_string_test.exs b/test/tesla/secret_string_test.exs new file mode 100644 index 00000000..ed62aab7 --- /dev/null +++ b/test/tesla/secret_string_test.exs @@ -0,0 +1,33 @@ +defmodule Tesla.SecretStringTest do + use ExUnit.Case, async: true + doctest Tesla.SecretString + + alias Tesla.SecretString + + test "inspect never prints the value, at any nesting" do + secret = SecretString.new("Bearer s3cret") + + assert inspect(secret) == "#Tesla.SecretString" + refute inspect([{"authorization", secret}]) =~ "s3cret" + refute inspect(%{opts: [token: secret]}, pretty: true) =~ "s3cret" + end + + test "to_string and interpolation return the value" do + secret = SecretString.new("s3cret") + + assert to_string(secret) == "s3cret" + assert "Bearer #{secret}" == "Bearer s3cret" + end + + test "a client holding secrets in middleware options does not print them" do + client = + Tesla.client([ + {Tesla.Middleware.Headers, [{"authorization", SecretString.new("Bearer s3cret-1")}]}, + {Tesla.Middleware.BearerAuth, token: SecretString.new("s3cret-2")}, + {Tesla.Middleware.BasicAuth, %{username: "u", password: SecretString.new("s3cret-3")}} + ]) + + refute inspect(client) =~ "s3cret" + refute inspect(%Tesla.Env{__client__: client}) =~ "s3cret" + end +end