Skip to content

Commit e48fe3c

Browse files
committed
Strip sensitive headers on cross-origin redirects.
Given the lack of established best practicesfor handling redirects on WebSocket connections, we mimic best practices for HTTP libraries, even though vulnerabilities affecting HTTP don't always translate to WebSocket, notably because it's unlikely that an attacker would be able to control the URI to which a Python WebSocket client connects, without controlling the client entirely. Thank Nadav Magier for reporting this security hardening opportunity.
1 parent 8dcff08 commit e48fe3c

4 files changed

Lines changed: 73 additions & 1 deletion

File tree

docs/project/changelog.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ notice.
3535
Improvements
3636
............
3737

38+
* Stripped ``Authorization``, ``Cookie``, and ``Proxy-Authorization`` headers
39+
when clients follow cross-origin redirects, a security hardening measure.
40+
3841
* Added wheels for ARMv7, PowerPC, RISC-V, and S/390.
3942

4043
.. _16.0:

src/websockets/asyncio/client.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from typing import Any, Callable, Literal, cast
1313

1414
from ..client import ClientProtocol, backoff
15-
from ..datastructures import HeadersLike
15+
from ..datastructures import Headers, HeadersLike
1616
from ..exceptions import (
1717
InvalidMessage,
1818
InvalidProxyMessage,
@@ -529,6 +529,17 @@ def process_redirect(self, exc: Exception) -> Exception | str:
529529
f"with an explicit host or port"
530530
)
531531

532+
# Strip credentials to avoid leaking them to a different origin.
533+
if self.additional_headers is not None:
534+
self.additional_headers = Headers(
535+
(
536+
(key, value)
537+
for key, value in Headers(self.additional_headers).raw_items()
538+
if key.lower()
539+
not in ["authorization", "cookie", "proxy-authorization"]
540+
)
541+
)
542+
532543
return new_uri
533544

534545
# ... = await connect(...)

src/websockets/legacy/client.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,17 @@ def handle_redirect(self, uri: str) -> None:
569569
if not old_wsuri.secure and new_wsuri.secure:
570570
factory.keywords["secure"] = True
571571
self._create_connection.keywords.setdefault("ssl", True)
572+
# Strip credentials to avoid leaking them to a different origin.
573+
extra_headers = factory.keywords.get("extra_headers")
574+
if extra_headers is not None: # pragma: no cover
575+
factory.keywords["extra_headers"] = Headers(
576+
(
577+
(key, value)
578+
for key, value in Headers(extra_headers).raw_items()
579+
if key.lower()
580+
not in ["authorization", "cookie", "proxy-authorization"]
581+
)
582+
)
572583
# Replace secure, host, and port arguments of the protocol factory.
573584
factory = functools.partial(
574585
factory.func,

tests/asyncio/test_client.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,53 @@ def redirect(connection, request):
350350
"cannot follow redirect to ws://invalid/ with a preexisting socket",
351351
)
352352

353+
async def test_cross_origin_redirect_strips_credentials(self):
354+
"""Client strips credentials when following a cross-origin redirect."""
355+
356+
def redirect(connection, request):
357+
response = connection.respond(http.HTTPStatus.FOUND, "")
358+
response.headers["Location"] = get_uri(other_server)
359+
return response
360+
361+
async with serve(*args, process_request=redirect) as server:
362+
async with serve(*args) as other_server:
363+
async with connect(
364+
get_uri(server),
365+
additional_headers={
366+
"Authorization": "Bearer secret",
367+
"Cookie": "session=secret",
368+
"Proxy-Authorization": "Basic secret",
369+
"X-Custom": "keep",
370+
},
371+
) as client:
372+
self.assertNotIn("Authorization", client.request.headers)
373+
self.assertNotIn("Cookie", client.request.headers)
374+
self.assertNotIn("Proxy-Authorization", client.request.headers)
375+
self.assertIn("X-Custom", client.request.headers)
376+
377+
async def test_same_origin_redirect_preserves_credentials(self):
378+
"""Client preserves credentials when following a same-origin redirect."""
379+
380+
def redirect(connection, request):
381+
if request.path == "/redirect":
382+
response = connection.respond(http.HTTPStatus.FOUND, "")
383+
response.headers["Location"] = "/"
384+
return response
385+
386+
async with serve(*args, process_request=redirect) as server:
387+
async with connect(
388+
get_uri(server) + "/redirect",
389+
additional_headers={
390+
"Authorization": "Bearer secret",
391+
"Cookie": "session=secret",
392+
"Proxy-Authorization": "Basic secret",
393+
"X-Custom": "keep",
394+
},
395+
) as client:
396+
self.assertIn("Authorization", client.request.headers)
397+
self.assertIn("Cookie", client.request.headers)
398+
self.assertIn("Proxy-Authorization", client.request.headers)
399+
353400
async def test_invalid_uri(self):
354401
"""Client receives an invalid URI."""
355402
with self.assertRaises(InvalidURI):

0 commit comments

Comments
 (0)