Skip to content

feat: redact middleware and adapter options when inspecting Tesla.Client - #939

Open
BlueCollarChris wants to merge 3 commits into
elixir-tesla:masterfrom
BlueCollarChris:feat/redacting-client-inspect
Open

BlueCollarChris wants to merge 3 commits into
elixir-tesla:masterfrom
BlueCollarChris:feat/redacting-client-inspect

Conversation

@BlueCollarChris

Copy link
Copy Markdown

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 same applies to an API key in Tesla.Middleware.Query, credentials in a custom middleware, or adapter options. The Logger.error("...#{inspect(env)}") pattern is common in error handling, so this tends to surface as a credential in a production log aggregator rather than in a debugging session. We found it that way in a service that wraps a third-party API client built on Tesla: the provider API key appeared in the logs on every non-2xx response.

Change

An Inspect implementation for Tesla.Client that shows middleware and adapter modules but redacts their options by default:

iex> client = Tesla.client([{Tesla.Middleware.Headers, [{"authorization", "Bearer s3cret"}]}, Tesla.Middleware.JSON])
iex> inspect(client)
"#Tesla.Client<adapter: nil, middleware: [{Tesla.Middleware.Headers, :redacted}, Tesla.Middleware.JSON]>"
  • Middleware with options render as {Module, :redacted}; middleware without options render as the bare module; function middleware/adapters render as :fn.
  • post middleware is shown only when present.
  • Because __client__ is rendered through this implementation, inspect(env) no longer prints the client's secrets either.
  • Opt back in to the full rendering for debugging with config :tesla, inspect: :full. This follows the existing Application.get_env(:tesla, ...) convention used for the adapter and the Logger middleware.
  • Tesla.Client.middleware/1 and Tesla.Client.adapter/1 are unchanged and remain the programmatic way to read a client's configuration.

Docs: a @moduledoc for Tesla.Client (it had none) and a short "Inspecting a client" section in guides/explanations/0.client.md.

Why redact by default rather than opt in

inspect/1 output is not a stable API and nothing in Tesla parses it. The leak is silent and lands in the place people look least (production logs), so the safer default seems right, with the previous rendering one config line away. Happy to flip it to opt-in if you'd prefer to keep current behaviour as the default.

Not covered here

Request headers on the env itself (env.headers) still print when an env is inspected before or after a request, since they are legitimately part of the env. Redacting those is a bigger behavioural question (which headers, and whether Tesla.Middleware.Logger's :filter_headers should be shared with it), so I've kept this PR to the client, which is where the configuration secrets live.

Tests

test/tesla/client_test.exs gains an Inspect describe block: defaults redact header values, base URL and adapter options while keeping module names; an env embedding the client does not print the client's secrets; inspect: :full restores the original forms; empty client; post middleware. Doctests in the new moduledoc run via the existing doctest Tesla.Client.

mix test test/tesla/client_test.exs   9 doctests, 39 tests, 0 failures
mix test (non-adapter suites)         15 doctests, 700 tests, 0 failures
mix format --check-formatted          clean

A %Tesla.Client{} holds every middleware with its options, and a
%Tesla.Env{} embeds the client as __client__. With the default Inspect,
`inspect(env)` therefore prints an Authorization header handed to
Tesla.Middleware.Headers, an API key in Tesla.Middleware.Query, or any
other secret a middleware was configured with — and the pattern
`Logger.error("request failed: #{inspect(env)}")` is common enough that
this reaches production log streams.

Add an Inspect implementation for Tesla.Client that renders middleware
and adapter module names but redacts their options:

    #Tesla.Client<adapter: {Tesla.Adapter.Finch, :redacted},
                  middleware: [{Tesla.Middleware.Headers, :redacted}, Tesla.Middleware.JSON]>

`config :tesla, inspect: :full` opts back in to the previous rendering
for debugging. Tesla.Client.middleware/1 and adapter/1 are unchanged and
remain the way to read a client's configuration from code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@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

Medium Risk
Changes default inspect/1 output (debugging may need inspect: :full) but reduces accidental secret exposure in production logs; no change to request execution or API accessors.

Overview
Prevents credentials from leaking into logs when inspect/1 is used on a %Tesla.Client{} or a %Tesla.Env{} that embeds __client__ (e.g. Logger.error("...#{inspect(env)}")).

Adds a custom Inspect implementation for Tesla.Client that prints middleware and adapter module names but replaces option tuples with {Module, :redacted}; bare modules, :fn for function middleware/adapters, and optional post stack when non-empty. Full options are restored with config :tesla, inspect: :full. Tesla.Client.middleware/1 and adapter/1 are unchanged for programmatic access.

Documentation: new @moduledoc on Tesla.Client and an "Inspecting a client" section in the client guide. Tests cover default redaction, env embedding, :full mode, empty client, and post middleware.

Reviewed by Cursor Bugbot for commit 0071e29. 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

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 and others added 2 commits September 4, 2026 11:48
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…re/1

Build the rendering from the public user-form returned by
Tesla.Client.adapter/1 and Tesla.Client.middleware/1 instead of matching
the runtime {module, :call, [opts]} tuples and duplicating unruntime/1.
The Inspect impl no longer depends on the internal stack representation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@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
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?

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