Skip to content

ollama: client_kwargs is mutated in place, so URL credentials leak into the caller's dict and across model instances #40926

Description

@kodurd

Submission checklist

  • This is a bug, not a usage question.
  • I added a clear and descriptive title that summarizes this issue.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangChain rather than my code.
  • The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package).
  • This is not related to the langchain-community package.
  • I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS.

Package (Required)

  • langchain
  • langchain-openai
  • langchain-anthropic
  • langchain-classic
  • langchain-core
  • langchain-model-profiles
  • langchain-tests
  • langchain-text-splitters
  • langchain-chroma
  • langchain-deepseek
  • langchain-exa
  • langchain-fireworks
  • langchain-groq
  • langchain-huggingface
  • langchain-mistralai
  • langchain-nomic
  • langchain-ollama
  • langchain-openrouter
  • langchain-perplexity
  • langchain-qdrant
  • langchain-typesafe
  • langchain-xai
  • Other / not sure / general

Related Issues / PRs

#38899 (same defect, reported 2026-07-17 and auto-closed without review)

Reproduction Steps / Example Code (Python)

from langchain_ollama import ChatOllama

# A config dict the caller owns and reuses, with their own headers nested inside.
caller_headers = {"X-Tenant": "acme"}
shared_kwargs = {"headers": caller_headers}

ChatOllama(
    model="llama3",
    base_url="https://alice:s3cret@ollama.internal:11434",
    client_kwargs=shared_kwargs,
    validate_model_on_init=False,
)

print("caller's dict:  ", shared_kwargs)
print("nested headers: ", caller_headers)

# The same dict reused for a second model whose URL has no credentials at all.
second = ChatOllama(
    model="llama3",
    base_url="http://localhost:11434",
    client_kwargs=shared_kwargs,
    validate_model_on_init=False,
)
print("second client sends Authorization:", second._client._client.headers.get("authorization"))


Output on current `master` (`a9780cd`):


caller's dict:   {'headers': {'X-Tenant': 'acme', 'Authorization': 'Basic YWxpY2U6czNjcmV0'}}
nested headers:  {'X-Tenant': 'acme', 'Authorization': 'Basic YWxpY2U6czNjcmV0'}
second client sends Authorization: Basic YWxpY2U6czNjcmV0


Expected: `client_kwargs` and the dict nested inside it are left as the caller passed them, and the second model — whose `base_url` carries no credentials — sends no `Authorization` header.

Error Message and Stack Trace (if applicable)

No exception is raised. The credential is silently written into the caller's dict.

Description

client_kwargs is documented as configuration the caller supplies ("Additional kwargs to pass to the httpx clients. Pass headers in here."). Constructing a model writes into it instead of reading from it.

Where. All three model classes share the same block in their _set_clients validator:

  • langchain_ollama/chat_models.py:951
  • langchain_ollama/llms.py:332
  • langchain_ollama/embeddings.py:298
client_kwargs = self.client_kwargs or {}

cleaned_url, auth_headers = parse_url_with_auth(self.base_url)
merge_auth_headers(client_kwargs, auth_headers)

self.client_kwargs or {} is not a copy — when the field is a non-empty dict it is the same object the caller passed. merge_auth_headers (langchain_ollama/_utils.py:142) then writes into it, and into the dict nested under "headers":

if auth_headers:
    headers = client_kwargs.get("headers", {})
    headers.update(auth_headers)      # mutates the caller's nested dict
    client_kwargs["headers"] = headers

The helper documents itself as in-place, so the defect is that caller-owned data reaches it uncopied.

Three consequences, in increasing order of severity.

  1. The caller's client_kwargs gains an Authorization entry it never set, and so does the nested headers dict if they kept a reference to it. Anything that later logs or serialises that config exposes the credential.

  2. The credential is also on the model object afterwards, since self.client_kwargs is the mutated dict — so model_dump() and similar carry it.

  3. Reusing one client_kwargs dict across instances carries the first URL's credential to every later one. In the example above the second model points at localhost with no credentials in its URL, and its client still sends Alice's Authorization header — a credential for one host transmitted to another. Sharing a single config dict across several models is an ordinary pattern, and nothing in the field's documentation suggests it is unsafe.

Only a non-empty client_kwargs triggers it: an empty or None field makes or {} produce a fresh dict, so the mutation lands somewhere harmless. In other words it misfires exactly when the caller has supplied something worth corrupting.

Proposed fix. Two small changes:

  • take a copy at the three call sites — client_kwargs = dict(self.client_kwargs or {})
  • in merge_auth_headers, build a new mapping instead of updating the caller's nested one — client_kwargs["headers"] = {**client_kwargs.get("headers", {}), **auth_headers}

Both are needed: the shallow copy alone still leaves the nested headers dict shared. That is +6/-6 across the four files, plus regression tests. The module already applies this discipline elsewhere — _convert_messages_to_ollama_messages copies with the comment "shallow copy to avoid mutating caller's list".

Verification of the proposed fix, on master a9780cd in libs/partners/ollama:

  • the repro above leaves both dicts untouched and the second client sends no Authorization
  • auth still reaches the client it belongs to: with credentials in base_url, all three classes still send Authorization alongside the caller's own headers (covered by a test, so the copy cannot be "fixed" into dropping the feature)
  • nine regression tests added (three cases across the three classes); six of them fail on current master and pass with the fix
  • make test — 105 passed, 2 skipped (96 passed on master, plus the 9 new)
  • make lint — lint_imports, ruff check, ruff format --diff and ty check all clean on langchain_ollama and tests
  • pytest -m compile tests/integration_tests — passes
  • also ran the auth suite on Python 3.10 and 3.14, and the full unit suite on Python 3.12 with pydantic~=2.7.0, matching this package's CI matrix
  • uv.lock untouched

Prior report. This was diagnosed first by Ayaan Gazali (@ayaangazali) in #38899, with the same root cause, the same three affected classes and a fix already prepared on a fork. That issue was closed the same day by the automated triage bot because it carried no issue type, i.e. it had been filed through the API rather than the web form — so it was never read on its merits, and the defect is still present two and a half months later, including in the released langchain-ollama 1.1.0. I am filing this through the web form so the report survives triage. If Ayaan Gazali (@ayaangazali) would like to carry the fix themselves I am glad to step aside; otherwise I would like to be assigned and will open the PR with the change and the regression tests described above.

System Info

master @ a9780cd (2026-09-29)
langchain-ollama 1.1.0 (as released)

Python 3.12.13
langchain-core 1.6.6

Social handles (optional)

No response

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugRelated to a bug, vulnerability, unexpected error with an existing featureexternalollama`langchain-ollama` package issues & PRs

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions