Skip to content

fix: redact API key from provider error output (#91)#92

Open
sgriffiths wants to merge 1 commit into
mainfrom
fix/91-redact-api-key-in-errors
Open

fix: redact API key from provider error output (#91)#92
sgriffiths wants to merge 1 commit into
mainfrom
fix/91-redact-api-key-in-errors

Conversation

@sgriffiths

Copy link
Copy Markdown
Contributor

What changed

Fixes the credential leak reported in #91: the OpenAI and Anthropic providers printed the API key in cleartext when the key contained an invalid HTTP-header character.

Two layers of defence:

  1. Strip the key on read (both providers) — a trailing \r from a CRLF .env (the common Windows/WSL case) is trimmed, so the header is valid and the request just proceeds. Fixes the reported trigger outright.
  2. Redact on error — new scrub_secrets() in providers/base.py; provider request-failure paths route their message through it, removing any Authorization / x-api-key value (and its printable segments — an exception repr escapes control chars, so the raw value alone wasn't enough) before it reaches the user. Backstops any other malformed-key case that survives .strip().

Why

When a key had a stray header-invalid character, the providers did:

except Exception as exc:
    raise ProviderError(f"OpenAI request failed: {exc}") from exc

exc here is ValueError: Invalid header value b'Bearer sk-...\r' — the message embeds the key. It landed in the terminal error panel (and any CI log / pasted bug report) in cleartext. CRLF .env files are common on Windows/WSL, so this was reachable by accident.

How tested

  • All existing tests pass (pytest tests/ — 511 passed, 3 skipped)
  • New tests added

New tests in tests/test_providers.py::TestCredentialRedaction (+ a CRLF-strip case in TestOpenAIProviderAuth):

  • scrub_secrets redacts Bearer and x-api-key header values, and leaves non-secret headers untouched
  • CRLF-terminated key is trimmed to a valid Bearer <key> header
  • End-to-end: an API key with an embedded newline (survives .strip(), so it exercises the redaction path) never appears in the raised ProviderError — asserted for both OpenAI and Anthropic, fully offline

Writing that end-to-end test caught a real gap mid-development: the first redaction pass missed the key because the exception repr escapes the newline (\n → literal backslash-n), so the raw value no longer matched. The final scrub_secrets also redacts the value's printable segments, which do appear verbatim.

  • ruff + mypy clean.

Scope

  • Python runtime: oas_cli/providers/{openai_http,anthropic_http,base}.py.
  • npm runtime: not affected by this path — it interpolates only res.status + response body into OAError, never the header value. (A parity .trim() on the key there is a reasonable low-priority follow-up, but there's no leak to fix.)

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Refactoring / CI / tooling

Checklist

  • Code follows project style (ruff check . && ruff format --check .)
  • Self-review completed
  • Version bumped if needed — no bump here; changelog entry added under [Unreleased] → Security

When an API key carried a character invalid in an HTTP header — most
commonly a trailing CR from a CRLF .env on Windows/WSL — the OpenAI and
Anthropic providers interpolated the underlying exception into a
ProviderError. That exception's message embeds the raw Authorization /
x-api-key header value, so the key was printed in cleartext to the
terminal and any CI logs.

Two layers of defence:

- Strip surrounding whitespace from the key on read, in both providers.
  This fixes the common CRLF-.env trigger outright: the header becomes
  valid and the request proceeds normally.
- Add scrub_secrets() in providers/base.py and route provider request
  errors through it, redacting any credential header value (and its
  printable segments, since an exception repr escapes control chars) from
  the message. Backstops other malformed-key cases that survive strip().

Tests cover the CRLF-strip behaviour, scrub_secrets directly, and an
end-to-end assertion that a key with an embedded newline never appears in
the raised OpenAI/Anthropic error. The npm runtime is unaffected by this
path (it never interpolates the header value into its error).

Closes #91
@sgriffiths
sgriffiths requested a review from aswhitehouse as a code owner July 22, 2026 23:20
@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
open-agent-spec Ready Ready Preview, Comment Jul 22, 2026 11:20pm

Request Review

@aswhitehouse aswhitehouse left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Real bug, right fix shape — strip on read + redact on error is the correct two-layer defence, and catching the repr()-escaped control-char gap mid-development is exactly the kind of thing that makes this stick. CI green. A few items before merge:

Should-fix

1. Changelog structure. Inserting ### Security between Spec bullets orphaned the conformance README note under Security (line 18 now sits under the Security heading). Move that bullet back under ### Spec.

2. Exception __cause__ still holds the raw key. After scrubbing you still do raise ProviderError(...) from exc (openai_http.py ~263, and the Anthropic equivalents). The CLI path prints str(OARunError) so the happy path is safe, but any full traceback (uncaught, --tb=long, logging that walks __cause__) will still print the original ValueError with the cleartext header. Prefer raise ProviderError(...) from None once the message is scrubbed.

3. End-to-end Anthropic assertion is weak for the escaped-repr case. bad_key contains a real newline; after repr() escaping that exact string is never in the message anyway, so assert bad_key not in str(exc) (line 296) can pass even if scrubbing failed. Assert the printable segments: "sk-ant-abc" / "def-embedded-newline" (and tighten the OpenAI test the same way instead of the soft "abc" not in msg or REDACTED in msg).

Nits (non-blocking)

  • npm .trim() follow-up — agreed there's no leak today, but trimming on read is cheap hygiene.
  • len(variant) >= 4 — fine; just be aware "Bearer" itself gets redacted as a segment (harmless).

Happy to approve once 1–3 are sorted.

Comment thread CHANGELOG.md

### Security
- **API key no longer leaked in provider error output** — when an API key carried an invalid header character (most commonly a trailing `\r` from a CRLF `.env` on Windows/WSL), the OpenAI and Anthropic providers interpolated the underlying exception — whose message embeds the raw `Authorization` / `x-api-key` value — straight into a user-facing `ProviderError`, printing the key in cleartext to the terminal and any CI logs. Keys are now stripped of surrounding whitespace on read (fixing the common CRLF trigger outright), and any credential header value is redacted from provider error messages as a defence-in-depth backstop for other malformed-key cases. The npm runtime was not affected by this path.
- Conformance README notes that cases pin the **minimum** `open_agent_spec` version they require, not the suite version.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This Spec bullet about the conformance README note got pulled under ### Security when the section was inserted. Move it back under ### Spec so Security only contains the credential-leak fix.

except Exception as exc:
raise ProviderError(f"OpenAI request failed: {exc}") from exc
msg = scrub_secrets(str(exc), all_headers)
raise ProviderError(f"OpenAI request failed: {msg}") from exc

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

After scrubbing, prefer from None over from exc. Chaining preserves the original ValueError (with the cleartext header) on __cause__, so any full traceback still leaks the key even though str(ProviderError) is clean. Same change needed on both Anthropic sites.

Comment thread tests/test_providers.py
"endpoint": "https://api.anthropic.com/v1/messages",
},
)
assert bad_key not in str(exc_info.value)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

bad_key contains a literal newline, so after the exception repr() escapes it this assertion is almost vacuously true. Assert the printable segments scrubbing must remove, e.g. assert "sk-ant-abc" not in msg and assert "def-embedded-newline" not in msg (and the same tightening on the OpenAI test above).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants