Skip to content
Merged
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
25 changes: 25 additions & 0 deletions guides/explanations/0.client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<redacted>}]]} ...
```

`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.
3 changes: 2 additions & 1 deletion lib/tesla/middleware/basic_auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/tesla/middleware/bearer_auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions lib/tesla/middleware/headers.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
defmodule Tesla.Middleware.Headers do
@moduledoc """
@moduledoc ~S"""
Set default headers for all requests

## Examples
Expand All @@ -13,14 +13,29 @@ 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

@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
57 changes: 57 additions & 0 deletions lib/tesla/secret_string.ex
Original file line number Diff line number Diff line change
@@ -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<redacted>"
iex> inspect([{"authorization", secret}])
~s([{"authorization", #Tesla.SecretString<redacted>}])
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}
Comment thread
BlueCollarChris marked this conversation as resolved.

defimpl Inspect do
def inspect(_secret, _opts), do: "#Tesla.SecretString<redacted>"
end

defimpl String.Chars do
def to_string(secret), do: secret.value
end
end
7 changes: 7 additions & 0 deletions test/tesla/middleware/basic_auth_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions test/tesla/middleware/bearer_auth_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]
Comment thread
BlueCollarChris marked this conversation as resolved.
end
end
8 changes: 8 additions & 0 deletions test/tesla/middleware/header_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
33 changes: 33 additions & 0 deletions test/tesla/secret_string_test.exs
Original file line number Diff line number Diff line change
@@ -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<redacted>"
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
Loading