Skip to content

feat: add Tesla.SecretString to keep secrets out of inspect output - #939

Merged
yordis merged 1 commit into
elixir-tesla:masterfrom
BlueCollarChris:feat/redacting-client-inspect
Sep 25, 2026
Merged

yordis merged 1 commit into
elixir-tesla:masterfrom
BlueCollarChris:feat/redacting-client-inspect

Conversation

@BlueCollarChris

@BlueCollarChris BlueCollarChris commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A %Tesla.Client{} carries every middleware together with its options, and a %Tesla.Env{} embeds the client as __client__. Both use the default Inspect, so this:

client = Tesla.client([{Tesla.Middleware.Headers, [{"authorization", "Bearer s3cret"}]}])
{:ok, env} = Tesla.get(client, "/users")
Logger.error("request failed: #{inspect(env)}")

writes Bearer s3cret to the log. The Logger.error("...#{inspect(env)}") pattern is common in error handling, so this tends to surface as a credential in a production log aggregator. We found it that way in a service wrapping a third-party API client built on Tesla: the provider API key appeared in the logs on every non-2xx response.

Change

Following the review discussion, secrecy is made explicit on the value rather than by redacting the client's Inspect output. This PR adds Tesla.SecretString:

iex> secret = Tesla.SecretString.new("Bearer s3cret")
iex> inspect(secret)
"#Tesla.SecretString<redacted>"
iex> to_string(secret)
"Bearer s3cret"
  • Inspect always renders #Tesla.SecretString<redacted>, however deeply the value is nested: in middleware options, in a %Tesla.Client{}, or in the __client__ of a %Tesla.Env{}.
  • String.Chars returns the value, so middleware that builds a header by interpolation works with a wrapped value unchanged. That also means "#{secret}" prints it; the docs call this out.
  • Tesla.Middleware.Headers unwraps Tesla.SecretString values when it puts them on the request.
  • Tesla.Middleware.BearerAuth (:token) and Tesla.Middleware.BasicAuth (:password) accept wrapped values with no code change, via interpolation; their option docs now mention it.
  • Tesla.Client's Inspect output is unchanged. Nothing is redacted unless the caller wraps it.

Usage:

Tesla.client([
  {Tesla.Middleware.Headers, [{"authorization", Tesla.SecretString.new("Bearer #{token}")}]}
])

Tesla.client([{Tesla.Middleware.BearerAuth, token: Tesla.SecretString.new(token)}])

Docs: @moduledoc for Tesla.SecretString, a "Secret header values" section in Tesla.Middleware.Headers, and a "Secrets in middleware options" section in guides/explanations/0.client.md.

Not covered here

  • Opt-in only. Existing callers passing plain strings still leak through inspect(client) / inspect(env) until they wrap their values. Wrapping automatically (e.g. a build-time hook letting middleware wrap their own secret options) would be a separate proposal.
  • Request headers on the env. Once Headers / BearerAuth / BasicAuth run, env.headers holds the real value, since that is what goes on the wire. Inspecting the env mid-request (in a custom middleware, or Tesla.Middleware.Logger at debug level) still shows it; Tesla.Middleware.Logger's :filter_headers covers the latter. Adapters replace env.headers with the response headers, so a returned env does not carry them.
  • Adapter options and other middleware (e.g. Tesla.Middleware.Query) are not unwrapped here.

Tests

  • test/tesla/secret_string_test.exs: redaction at any nesting, to_string/interpolation, and a client holding wrapped secrets in Headers, BearerAuth and BasicAuth that prints none of them (directly or embedded in an env). Doctests run via doctest Tesla.SecretString.
  • Headers, BearerAuth and BasicAuth tests each gain a case putting a wrapped value on the request.
mix test                           24 doctests, 1224 tests, 0 failures (100% coverage)
mix compile --warnings-as-errors   clean
mix format --check-formatted       clean

@BlueCollarChris
BlueCollarChris requested a review from a team as a code owner September 4, 2026 14:33
Copilot AI lite review requested due to automatic review settings September 4, 2026 14:33
@cursor

cursor Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

PR Summary

Low Risk
Additive, opt-in API for logging safety; only Headers behavior changes for wrapped values (unwrap on request), with no change to default plain-string usage.

Overview
Adds Tesla.SecretString, a wrapper that shows as #Tesla.SecretString<redacted> in inspect/1 but still exposes the real string via String.Chars for request building.

Tesla.Middleware.Headers now unwraps secret-wrapped header values before they are applied to the env; BearerAuth and BasicAuth document wrapping :token / :password and continue to work through existing "Bearer #{token}" / credential interpolation.

The client guide gains a “Secrets in middleware options” section describing why tokens in client/env inspect output are risky and how to wrap them. Tests cover inspect redaction, interpolation, headers unwrapping, and clients/envs that hold wrapped secrets without printing raw values in inspect.

Reviewed by Cursor Bugbot for commit f43a030. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new test helper that temporarily sets :tesla, :inspect restores prior config using a truthiness check that can mis-restore falsy values, risking incorrect global config cleanup.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR hardens Tesla’s developer-facing logging/inspection behavior by implementing a custom Inspect protocol for Tesla.Client that redacts middleware and adapter options by default, reducing the risk of credential leakage when %Tesla.Env{} (which embeds __client__) is inspected.

Changes:

  • Add Inspect implementation for Tesla.Client that prints module names but redacts options unless config :tesla, inspect: :full is set.
  • Add a new @moduledoc to Tesla.Client documenting the redaction behavior and opt-in full inspection.
  • Add tests and guide documentation covering default redaction, :full mode, env embedding, and post middleware rendering.
File summaries
File Description
lib/tesla/client.ex Adds @moduledoc and a redacting Inspect implementation for Tesla.Client.
test/tesla/client_test.exs Adds coverage for redacted vs full inspection, env embedding, and post middleware rendering.
guides/explanations/0.client.md Documents the new default redaction behavior and how to opt into full inspect output.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread test/tesla/client_test.exs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The change is well-scoped, addresses a concrete secret-leak risk, and includes targeted tests and documentation to validate and explain the new behavior.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

lib/tesla/client.ex:224

  • The redaction logic currently inspects Tesla’s internal runtime tuples (e.g. {Module, :call, [opts]}) and re-implements unruntime/1 inside the Inspect impl. This couples Inspect to internal representation details and duplicates the unruntime logic already maintained in Tesla.Client.adapter/1 and Tesla.Client.middleware/1, increasing the risk that future changes to runtime stack representation will update one place but not the other.

You can avoid this by first converting adapter/middleware stacks back to the public “user form” via Tesla.Client.adapter/1 / Tesla.Client.middleware/1 (using a temporary %Tesla.Client{pre: stack} for post), and then applying redaction to those user-form entries.

  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@BlueCollarChris
BlueCollarChris force-pushed the feat/redacting-client-inspect branch from fa5e5f6 to 0071e29 Compare September 4, 2026 15:49
@yordis

yordis commented Sep 4, 2026

Copy link
Copy Markdown
Member

@BlueCollarChris, first of all, I strongly agree with the intent here. However, after thinking about it for a while, I wouldn’t take this approach for a few reasons:

  1. We would be implementing the Inspect protocol without knowing how its output will be consumed.
  2. The implementation’s complexity will continue to grow as middleware is added.
  3. It cannot reliably handle every middleware or determine which values are secrets.

Instead, I would make secrecy explicit by introducing a value type such as:

defmodule Tesla.SecretString do
  @opaque t :: %__MODULE__{value: String.t()}

  defstruct [:value]

  def new(value) when is_binary(value) do
    %__MODULE__{value: value}
  end
end

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

defimpl String.Chars, for: Tesla.SecretString do
  def to_string(secret) do
    secret.value
  end
end

Tesla.client([
  {Tesla.Middleware.Headers,
   [
     {"authorization", Tesla.SecretString.new("Bearer #{token}")}
   ]}
])

Middleware could then wrap sensitive values in Tesla.SecretString, keeping the redaction behavior close to the value itself and independent of where or how the surrounding structure is inspected.

Something around those lines,

@BlueCollarChris

BlueCollarChris commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Ill take a look at this approach a bit more.

@yordis

yordis commented Sep 12, 2026

Copy link
Copy Markdown
Member

@BlueCollarChris any updates?

@BlueCollarChris

Copy link
Copy Markdown
Contributor Author

@BlueCollarChris any updates?

Was able to find some time and working on it today

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 <noreply@anthropic.com>
@BlueCollarChris
BlueCollarChris force-pushed the feat/redacting-client-inspect branch from 0071e29 to f43a030 Compare September 22, 2026 18:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

It does not implement the promised default Tesla.Client redaction, and the documentation overstates protection for populated environments.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

Comment thread lib/tesla/secret_string.ex
@BlueCollarChris BlueCollarChris changed the title feat: redact middleware and adapter options when inspecting Tesla.Client feat: add Tesla.SecretString to keep secrets out of inspect output Sep 22, 2026
@yordis
yordis requested a balanced review from Copilot September 22, 2026 19:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

BearerAuth still emits "******" instead of interpolating the token, so the new bearer-token test cannot pass.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Comment thread test/tesla/middleware/bearer_auth_test.exs
@yordis
yordis merged commit 258d378 into elixir-tesla:master Sep 25, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants