Skip to content

fix(credentials): match the loopback exemption on the parsed host - #1962

Open
feiiiiii5 wants to merge 2 commits into
anthropics:mainfrom
feiiiiii5:fix/require-https-loopback-host
Open

feiiiiii5 wants to merge 2 commits into
anthropics:mainfrom
feiiiiii5:fix/require-https-loopback-host

Conversation

@feiiiiii5

Copy link
Copy Markdown

What was wrong

_require_https in src/anthropic/lib/credentials/_constants.py grants the cleartext-HTTP exemption to loopback by prefix-matching the URL string:

if lowered.startswith(("http://localhost", "http://127.0.0.1", "http://[::1]")):
    return

A prefix match accepts any host whose name merely begins with those characters, so a config that names a lookalike host passes the guard and the token POST goes out in cleartext to that host. Before / after, calling _require_https(url, field="base_url") directly:

Before / after
url before after
http://localhost:8080/oauth/token accepted accepted
http://[::1]:9000/token accepted accepted
http://LOCALHOST:1234/token accepted accepted
https://api.example.com/token accepted accepted
http://localhost.evil.example/token accepted rejected
http://127.0.0.1.evil.example/token accepted rejected
http://localhostfoo.example/token accepted rejected
http://localhost:8080@evil.example/token accepted rejected

The last row is the sharpest one: that URL carries localhost:8080 as userinfo, and the request it describes goes to evil.example (urlsplit("http://localhost:8080@evil.example/token").hostname is 'evil.example').

The value being protected is the body of the token exchange — the assertion JWT or a long-lived refresh token — and the existing test for this guard (test_config_base_url_http_rejected) states the reason it exists: "so a malicious config can't exfiltrate the assertion or refresh token". A malicious config is also exactly how this prefix would be reached in practice, since base_url comes from a config file or ANTHROPIC_BASE_URL.

What changed

The exemption is a property of the host, not of the string, so the guard now parses the URL and compares the parsed hostname against an exact loopback set:

_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
...
if urlsplit(lowered).hostname in _LOOPBACK_HOSTS:
    return

urlsplit(...).hostname already lowercases the host and strips the brackets from the [::1] form, so the two hand-rolled prefixes those used to approximate are no longer needed. The https:// branch is untouched, so a malformed https:// value is still accepted exactly as before.

This brings the Python SDK in line with the TypeScript SDK rather than inventing a policy: requireSecureTokenEndpoint in anthropic-sdk-typescript (src/lib/credentials/types.ts) parses with new URL() and compares the hostname for equality, with a comment about the bracketed-IPv6 form. No sibling PR is needed — the other SDK already has it.

How do you know it works

One parametrized test, test_config_base_url_localhost_lookalike_http_rejected, covering the four lookalike hosts through the public CredentialsFile() surface. On the base commit with the test file applied, all four cases fail (FAILED ...[http://localhost.evil.example], [http://127.0.0.1.evil.example], [http://localhostfoo.example], [http://localhost:8080@evil.example] → 4 failed).

On this branch:

  • pytest tests/lib/test_credentials.py -q → 286 passed, 2 skipped
  • pytest tests/test_client.py -q -k "credential or auth or base_url" → 12 passed
  • ruff check and ruff format --check on both files → clean
Pre-existing failures I ruled out

pytest tests/lib -q also reports 30 failures and 17 errors, all unrelated: every one is ModuleNotFoundError: No module named 'botocore' in tests/lib/test_bedrock.py, tests/lib/test_aws_auth.py and tests/lib/test_aws.py. botocore is an optional extra and is not installed in the venv I tested in; none of those files touch credentials.

## Risk, scope and notes
  • Behavior change: cleartext endpoints that used to be accepted are now rejected. That is the point, but anyone pointing base_url at a cleartext host whose name starts with localhost (a LAN dev box, a container named localhost-something) will start getting must use https. Loopback itself is unaffected, including http://[::1]:9000, http://LOCALHOST:1234 and http://127.0.0.1:80.
  • A stricter policy is possible — reject every non-https endpoint and add an explicit opt-in such as allow_insecure_loopback=True. That is an API change and I did not make it; the exact-host check is the minimal change that closes the hole. A schemeless value (localhost:8080) still parses to hostname is None and is rejected, same as before.
  • Not verified: no live token exchange was performed. The evidence is the unit test plus the parsed-host check above.
Looked at and left alone

_retry.py's two callers pass the shared backoff() helper a 1-indexed attempt inconsistently (_poller.py:33 passes attempt, _beta_session_runner.py:932 passes attempt - 1), shifting the first retry by a second. That is a behavior question, not a defect, so it is not in this PR.

_require_https granted the cleartext-HTTP exemption with a startswith on
the URL string, so any host whose name merely began with localhost or
127.0.0.1 passed the guard and received the assertion JWT or refresh token
in cleartext, including http://localhost:8080@evil.example, whose request
actually goes to evil.example. Compare urlsplit(url).hostname against an
exact loopback set, which is what the TypeScript SDK already does.

Co-Authored-By: opencode <opencode@users.noreply.github.com>
@feiiiiii5
feiiiiii5 requested a review from a team as a code owner September 29, 2026 20:07
@feiiiiii5
feiiiiii5 force-pushed the fix/require-https-loopback-host branch from 359f971 to 54a85d6 Compare October 8, 2026 06:21
@feiiiiii5

Copy link
Copy Markdown
Author

Three of my PRs here are waiting on a first look, and I would rather close a gap than leave them hanging, so one question per PR:

None of them needs a change from me, and each is small (2-3 files with tests). If one of the three is not something you intend to take, say so and I will close it rather than keep it open. Also happy for you to approve the fork workflow runs whenever you get to them.

@feiiiiii5

Copy link
Copy Markdown
Author

Still mergeable since the last update. The head has no check runs, so CI has not reported on it; the change matches the loopback exemption on the parsed host and the local tests pass. Could a maintainer trigger the workflow or take a look?

The loopback exemption accepted any URL whose host is a loopback name, so ftp://localhost, ws://localhost, file://localhost and scheme-relative //localhost bypassed the HTTPS requirement. Only http (not https, not other schemes) on a loopback host is exempt; add boundary tests for allowed loopback http URLs and rejected non-http schemes.

This branch has not been deployed

No deployments
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