Skip to content

fix(credentials): match parsed host in cleartext-HTTP guard#1738

Open
sean-kim05 wants to merge 1 commit into
anthropics:mainfrom
sean-kim05:fix/require-https-loopback-host-check
Open

fix(credentials): match parsed host in cleartext-HTTP guard#1738
sean-kim05 wants to merge 1 commit into
anthropics:mainfrom
sean-kim05:fix/require-https-loopback-host-check

Conversation

@sean-kim05

Copy link
Copy Markdown

What

_require_https (src/anthropic/lib/credentials/_constants.py) gates the credential token-exchange base_url: https:// is always allowed, and cleartext http:// is tolerated only for a loopback host so base_url="http://localhost:8080" works against a local oauth_server during testing.

The loopback exemption was a raw string prefix check:

lowered = url.lower().rstrip("/")
if lowered.startswith("https://"):
    return
if lowered.startswith(("http://localhost", "http://127.0.0.1", "http://[::1]")):
    return

A string prefix isn't a host. The following all pass the exemption today but are not loopback:

base_url real host (urlsplit(url).hostname)
http://localhost.evil.com/token localhost.evil.com
http://localhost@evil.com/token evil.com
http://127.0.0.1.evil.com/token 127.0.0.1.evil.com
http://127.0.0.1@evil.com/token evil.com
http://localhostx:8080 localhostx

So a misconfigured base_url pointing at any localhost/127.0.0.1-prefixed hostname — or one using a localhost@… userinfo segment whose real authority is elsewhere — sends the Workload Identity assertion JWT or a long-lived refresh token to that host over cleartext HTTP, the exact CWE-319 leak this guard exists to prevent. This tightens the defense-in-depth hardening added for #1578.

Fix

Parse the URL and compare the exact host against the loopback allowlist instead of prefix-matching the raw string:

_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})

parts = urlsplit(url)
scheme = parts.scheme.lower()
if scheme == "https":
    return
if scheme == "http" and (parts.hostname or "").lower() in _LOOPBACK_HOSTS:
    return
raise AnthropicError(...)

urlsplit drops any userinfo@ segment from .hostname, so http://localhost@evil.com resolves to host evil.com and is rejected. https:// URLs and genuine loopback HTTP endpoints (http://localhost[:port][/path], http://127.0.0.1, http://[::1]) are unaffected.

Tests

tests/lib/test_credentials.py, in the existing HTTPS-enforcement section:

  • test_localhost_lookalike_http_rejected — the look-alike hosts above now raise AnthropicError (fail without this change).
  • test_loopback_and_https_allowed — genuine loopback HTTP endpoints and any https:// URL still pass (regression guard, including case-insensitive host matching and [::1]).

Full tests/lib/test_credentials.py (193 tests) passes; ./scripts/lint (ruff + pyright + mypy) is clean.

Related: #1578.

`_require_https` exempted loopback endpoints from the https requirement
with a string prefix check (`url.startswith("http://localhost")`). That
also accepts hosts like `http://localhost.evil.com` and
`http://localhost@evil.com` (whose real host is `evil.com`), so a
misconfigured `base_url` would POST the Workload Identity assertion JWT
or a refresh token to an attacker-controlled host in cleartext — the
exact CWE-319 leak the guard exists to prevent.

Parse the URL and match the exact host against the loopback allowlist
{localhost, 127.0.0.1, ::1} instead of prefix-matching the raw string.
https URLs and genuine loopback HTTP endpoints are unaffected.
@sean-kim05 sean-kim05 requested a review from a team as a code owner July 6, 2026 06:45
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.

1 participant