Skip to content

fix: check callable before tuple in prepare_auth#7486

Closed
qizwiz wants to merge 1 commit into
psf:mainfrom
qizwiz:fix/prepare-auth-callable-before-tuple
Closed

fix: check callable before tuple in prepare_auth#7486
qizwiz wants to merge 1 commit into
psf:mainfrom
qizwiz:fix/prepare-auth-callable-before-tuple

Conversation

@qizwiz
Copy link
Copy Markdown

@qizwiz qizwiz commented May 25, 2026

Summary

prepare_auth currently checks isinstance(auth, tuple) before callable(auth). Any auth object that is both a 2-element tuple subclass and callable — for example an AuthBase subclass that also inherits from a 2-field namedtuple — silently takes the tuple branch. Its __call__ is never invoked; instead its fields are extracted and passed to HTTPBasicAuth, bypassing the custom handler entirely.

This PR closes the existing TODO:

# TODO: can be fixed by flipping the conditionals

What changes

src/requests/models.py — flip the conditional order:

# Before
if isinstance(auth, tuple) and len(auth) == 2:
    auth_handler = HTTPBasicAuth(*auth)
else:
    # TODO: can be fixed by flipping the conditionals
    auth_handler = cast(...)

# After
if callable(auth):
    auth_handler = cast(...)
elif isinstance(auth, tuple) and len(auth) == 2:
    auth_handler = HTTPBasicAuth(*auth)
else:
    auth_handler = cast(...)

tests/test_requests.py — regression test: a callable namedtuple auth handler must use __call__, not be downgraded to HTTPBasicAuth.

Why this is safe

Plain (username, password) tuples are unaffected: a bare tuple is not callable, so it still reaches the isinstance branch. HTTPBasicAuth is itself callable, but it is not a bare tuple, so it routes through callable correctly as before.

Proof of bug

Reproduced against requests 2.32.5:

from collections import namedtuple
from requests.auth import AuthBase
from requests import PreparedRequest, structures

_Base = namedtuple("TokenAuth", ["token", "scheme"])

class TokenAuth(_Base, AuthBase):
    def __call__(self, r):
        r.headers["Authorization"] = f"{self.scheme} {self.token}"
        return r

auth = TokenAuth(token="my-secret", scheme="Bearer")
req = PreparedRequest()
req.prepare_url("https://api.example.com", {})
req.headers = structures.CaseInsensitiveDict()
req.prepare_auth(auth)

print(req.headers["Authorization"])
# Output:  Basic bXktc2VjcmV0OkJlYXJlcg==
# Decoded: my-secret:Bearer
# Expected: Bearer my-secret

The custom __call__ was never invoked. The bearer token was silently encoded as a BasicAuth username.

This was found via Z3 formal verification of an ordering contract on prepare_auth (callable check must precede tuple check).

When auth is both callable and a 2-element tuple subclass (e.g. a
namedtuple-based AuthBase subclass), the previous code took the
isinstance(auth, tuple) branch first and silently constructed
HTTPBasicAuth(*auth) instead of invoking the object's __call__.
This meant the custom auth handler was never called and its fields were
extracted as Basic Auth credentials.

Flip the check order so callable(auth) is tested first. Plain
(username, password) tuples are unaffected: HTTPBasicAuth is itself
callable, but a bare tuple is not, so it still reaches the isinstance
branch correctly.

Closes the existing TODO comment: "can be fixed by flipping the conditionals"
@nateprewitt
Copy link
Copy Markdown
Member

Hi @qizwiz, thanks for the PR. The TODO was a note for ourselves and intentionally left for 2.34.x to keep the surface area of the change smaller. We already have a patch prepared for when we do the flip. I'll close this out as it won't be needed.

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.

2 participants