Skip to content

Commit a3b8129

Browse files
bdracoPierre-Louis PeetersPLPeeters
authored
[PR #9443/06b2398 backport][3.11] Fix handling of redirects with authentication (#9571)
Co-authored-by: Pierre-Louis Peeters <[email protected]> Co-authored-by: Pierre-Louis Peeters <[email protected]>
1 parent 153350d commit a3b8129

File tree

4 files changed

+114
-7
lines changed

4 files changed

+114
-7
lines changed

CHANGES/9436.bugfix.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Authentication provided by a redirect now takes precedence over provided ``auth`` when making requests with the client -- by :user:`PLPeeters`.

aiohttp/client.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ async def _request(
523523
warnings.warn("Chunk size is deprecated #1615", DeprecationWarning)
524524

525525
redirects = 0
526-
history = []
526+
history: List[ClientResponse] = []
527527
version = self._version
528528
params = params or {}
529529

@@ -614,13 +614,18 @@ async def _request(
614614
else InvalidUrlClientError
615615
)
616616
raise err_exc_cls(url)
617-
if auth and auth_from_url:
617+
# If `auth` was passed for an already authenticated URL,
618+
# disallow only if this is the initial URL; this is to avoid issues
619+
# with sketchy redirects that are not the caller's responsibility
620+
if not history and (auth and auth_from_url):
618621
raise ValueError(
619622
"Cannot combine AUTH argument with "
620623
"credentials encoded in URL"
621624
)
622625

623-
if auth is None:
626+
# Override the auth with the one from the URL only if we
627+
# have no auth, or if we got an auth from a redirect URL
628+
if auth is None or (history and auth_from_url is not None):
624629
auth = auth_from_url
625630

626631
if (

docs/client_advanced.rst

+38
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,44 @@ For *text/plain* ::
5656

5757
await session.post(url, data='Привет, Мир!')
5858

59+
Authentication
60+
--------------
61+
62+
Instead of setting the ``Authorization`` header directly,
63+
:class:`ClientSession` and individual request methods provide an ``auth``
64+
argument. An instance of :class:`BasicAuth` can be passed in like this::
65+
66+
auth = BasicAuth(login="...", password="...")
67+
async with ClientSession(auth=auth) as session:
68+
...
69+
70+
Note that if the request is redirected and the redirect URL contains
71+
credentials, those credentials will supersede any previously set credentials.
72+
In other words, if ``http://[email protected]`` redirects to
73+
``http://[email protected]``, the second request will be authenticated
74+
as ``other_user``. Providing both the ``auth`` parameter and authentication in
75+
the *initial* URL will result in a :exc:`ValueError`.
76+
77+
For other authentication flows, the ``Authorization`` header can be set
78+
directly::
79+
80+
headers = {"Authorization": "Bearer eyJh...0M30"}
81+
async with ClientSession(headers=headers) as session:
82+
...
83+
84+
The authentication header for a session may be updated as and when required.
85+
For example::
86+
87+
session.headers["Authorization"] = "Bearer eyJh...1OH0"
88+
89+
Note that a *copy* of the headers dictionary is set as an attribute when
90+
creating a :class:`ClientSession` instance (as a :class:`multidict.CIMultiDict`
91+
object). Updating the original dictionary does not have any effect.
92+
93+
In cases where the authentication header value expires periodically, an
94+
:mod:`asyncio` task may be used to update the session's default headers in the
95+
background.
96+
5997
.. note::
6098
``Authorization`` header will be removed if you get redirected
6199
to a different host or protocol.

tests/test_client_functional.py

+67-4
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import tarfile
1313
import time
1414
import zipfile
15-
from typing import Any, AsyncIterator, Optional, Type
15+
from typing import Any, AsyncIterator, Awaitable, Callable, List, Optional, Type
1616
from unittest import mock
1717

1818
import pytest
@@ -21,7 +21,7 @@
2121

2222
import aiohttp
2323
from aiohttp import Fingerprint, ServerFingerprintMismatch, hdrs, web
24-
from aiohttp.abc import AbstractResolver
24+
from aiohttp.abc import AbstractResolver, ResolveResult
2525
from aiohttp.client_exceptions import (
2626
ClientResponseError,
2727
InvalidURL,
@@ -35,8 +35,9 @@
3535
from aiohttp.client_reqrep import ClientRequest
3636
from aiohttp.connector import Connection
3737
from aiohttp.http_writer import StreamWriter
38-
from aiohttp.pytest_plugin import AiohttpClient, AiohttpServer, TestClient
39-
from aiohttp.test_utils import unused_port
38+
from aiohttp.pytest_plugin import AiohttpClient, AiohttpServer
39+
from aiohttp.test_utils import TestClient, TestServer, unused_port
40+
from aiohttp.typedefs import Handler
4041

4142

4243
@pytest.fixture
@@ -2888,6 +2889,68 @@ async def test_creds_in_auth_and_url() -> None:
28882889
await session.close()
28892890

28902891

2892+
async def test_creds_in_auth_and_redirect_url(
2893+
create_server_for_url_and_handler: Callable[[URL, Handler], Awaitable[TestServer]],
2894+
) -> None:
2895+
"""Verify that credentials in redirect URLs can and do override any previous credentials."""
2896+
url_from = URL("http://example.com")
2897+
url_to = URL("http://[email protected]")
2898+
redirected = False
2899+
2900+
async def srv(request: web.Request) -> web.Response:
2901+
nonlocal redirected
2902+
2903+
assert request.host == url_from.host
2904+
2905+
if not redirected:
2906+
redirected = True
2907+
raise web.HTTPMovedPermanently(url_to)
2908+
2909+
return web.Response()
2910+
2911+
server = await create_server_for_url_and_handler(url_from, srv)
2912+
2913+
etc_hosts = {
2914+
(url_from.host, 80): server,
2915+
}
2916+
2917+
class FakeResolver(AbstractResolver):
2918+
async def resolve(
2919+
self,
2920+
host: str,
2921+
port: int = 0,
2922+
family: socket.AddressFamily = socket.AF_INET,
2923+
) -> List[ResolveResult]:
2924+
server = etc_hosts[(host, port)]
2925+
assert server.port is not None
2926+
2927+
return [
2928+
{
2929+
"hostname": host,
2930+
"host": server.host,
2931+
"port": server.port,
2932+
"family": socket.AF_INET,
2933+
"proto": 0,
2934+
"flags": socket.AI_NUMERICHOST,
2935+
}
2936+
]
2937+
2938+
async def close(self) -> None:
2939+
"""Dummy"""
2940+
2941+
connector = aiohttp.TCPConnector(resolver=FakeResolver(), ssl=False)
2942+
2943+
async with aiohttp.ClientSession(connector=connector) as client, client.get(
2944+
url_from, auth=aiohttp.BasicAuth("user", "pass")
2945+
) as resp:
2946+
assert len(resp.history) == 1
2947+
assert str(resp.url) == "http://example.com"
2948+
assert resp.status == 200
2949+
assert (
2950+
resp.request_info.headers.get("authorization") == "Basic dXNlcjo="
2951+
), "Expected redirect credentials to take precedence over provided auth"
2952+
2953+
28912954
@pytest.fixture
28922955
def create_server_for_url_and_handler(aiohttp_server, tls_certificate_authority):
28932956
def create(url, srv):

0 commit comments

Comments
 (0)