Repository navigation
Conversation
_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
force-pushed
the
fix/require-https-loopback-host
branch
from
October 8, 2026 06:21
359f971 to
54a85d6
Compare
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. |
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
_require_httpsinsrc/anthropic/lib/credentials/_constants.pygrants the cleartext-HTTP exemption to loopback by prefix-matching the URL string: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
http://localhost:8080/oauth/tokenhttp://[::1]:9000/tokenhttp://LOCALHOST:1234/tokenhttps://api.example.com/tokenhttp://localhost.evil.example/tokenhttp://127.0.0.1.evil.example/tokenhttp://localhostfoo.example/tokenhttp://localhost:8080@evil.example/tokenThe last row is the sharpest one: that URL carries
localhost:8080as userinfo, and the request it describes goes toevil.example(urlsplit("http://localhost:8080@evil.example/token").hostnameis'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, sincebase_urlcomes from a config file orANTHROPIC_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:
urlsplit(...).hostnamealready 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. Thehttps://branch is untouched, so a malformedhttps://value is still accepted exactly as before.This brings the Python SDK in line with the TypeScript SDK rather than inventing a policy:
requireSecureTokenEndpointinanthropic-sdk-typescript(src/lib/credentials/types.ts) parses withnew 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 publicCredentialsFile()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 skippedpytest tests/test_client.py -q -k "credential or auth or base_url"→ 12 passedruff checkandruff format --checkon both files → cleanPre-existing failures I ruled out
pytest tests/lib -qalso reports 30 failures and 17 errors, all unrelated: every one isModuleNotFoundError: No module named 'botocore'intests/lib/test_bedrock.py,tests/lib/test_aws_auth.pyandtests/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.base_urlat a cleartext host whose name starts withlocalhost(a LAN dev box, a container namedlocalhost-something) will start gettingmust use https. Loopback itself is unaffected, includinghttp://[::1]:9000,http://LOCALHOST:1234andhttp://127.0.0.1:80.httpsendpoint and add an explicit opt-in such asallow_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 tohostname is Noneand is rejected, same as before.Looked at and left alone
_retry.py's two callers pass the sharedbackoff()helper a 1-indexed attempt inconsistently (_poller.py:33passesattempt,_beta_session_runner.py:932passesattempt - 1), shifting the first retry by a second. That is a behavior question, not a defect, so it is not in this PR.