Skip to content

Commit 30b4f44

Browse files
fix(google-auth-oauthlib): prevent ipv6 address reuse (#18463)
Extending the solution in #18166 We previously added a guard to prevent external processes sitting on the same port, and hijacking oauth calls. This PR extends that logic to also prevent processes using the ipv6 address Fixes: #18296
1 parent 1d9f468 commit 30b4f44

4 files changed

Lines changed: 181 additions & 3 deletions

File tree

‎packages/google-auth-oauthlib/google_auth_oauthlib/flow.py‎

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
5050
"""
5151

52+
import errno
5253
import hashlib
5354
import json
5455
import logging
@@ -492,14 +493,66 @@ class _ExclusiveWSGIServer(wsgiref.simple_server.WSGIServer):
492493
Setting `WSGIServer.allow_reuse_address` is not enough, since it sets `SO_REUSEADDR`
493494
and not `SO_EXCLUSIVEADDRUSE`. `SO_REUSEADDR` alone allows other processes to bind
494495
to the same address and port on Windows.
496+
497+
When bound to `localhost`, also reserves the IPv6 loopback (`::1`) so
498+
another process listening on `::1` cannot intercept the OAuth callback.
495499
"""
496500

497501
allow_reuse_address = False
502+
_PROBE_TIMEOUT_SECONDS = 0.1
498503

499-
def server_bind(self):
504+
def __init__(self, *args, **kwargs):
505+
self._ipv6_socket = None
506+
super().__init__(*args, **kwargs)
507+
508+
@staticmethod
509+
def is_listener_present(family: int, addr: str, port: int) -> bool:
510+
"""Check if another process is already listening on (addr, port) by
511+
attempting a test connection.
512+
513+
This is needed because on Windows, `bind()` on `::1` succeeds even when
514+
another process from the same user is already listening on all
515+
interfaces (`[::]`).
516+
"""
517+
try:
518+
with socket.socket(family, socket.SOCK_STREAM) as probe:
519+
probe.settimeout(_ExclusiveWSGIServer._PROBE_TIMEOUT_SECONDS)
520+
return probe.connect_ex((addr, port)) == 0
521+
except OSError:
522+
return False
523+
524+
@staticmethod
525+
def _set_exclusive_addr_use(sock: socket.socket) -> None:
500526
if sys.platform == "win32" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
501-
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
527+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
528+
529+
def _close_ipv6_socket(self):
530+
if self._ipv6_socket is not None:
531+
self._ipv6_socket.close()
532+
self._ipv6_socket = None
533+
534+
def server_bind(self):
535+
host = self.server_address[0]
536+
self._set_exclusive_addr_use(self.socket)
502537
super().server_bind()
538+
port = self.server_address[1]
539+
# Reserve IPv6 loopback (::1) so another process cannot intercept localhost callbacks.
540+
if host == "localhost" and port and hasattr(socket, "AF_INET6"):
541+
# base class (TCPServer) calls server_close on error
542+
if self.is_listener_present(socket.AF_INET6, "::1", port):
543+
raise OSError(errno.EADDRINUSE, "Address already in use")
544+
# Hold `::1` without calling `listen()` so no other process can claim
545+
# the port while the browser falls back from `::1` to `127.0.0.1`.
546+
try:
547+
self._ipv6_socket = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
548+
self._set_exclusive_addr_use(self._ipv6_socket)
549+
self._ipv6_socket.bind(("::1", port))
550+
except OSError:
551+
self._close_ipv6_socket()
552+
553+
def server_close(self):
554+
self._close_ipv6_socket()
555+
super().server_close()
503556

504557

505558
class _WSGIRequestHandler(wsgiref.simple_server.WSGIRequestHandler):

‎packages/google-auth-oauthlib/google_auth_oauthlib/interactive.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ def is_port_open(port):
5050
is_open = False
5151
else:
5252
is_open = True
53+
# Also verify that no process is already listening on IPv6 loopback (::1).
54+
if is_open and hasattr(socket, "AF_INET6"):
55+
is_open = (
56+
not google_auth_oauthlib.flow._ExclusiveWSGIServer.is_listener_present(
57+
socket.AF_INET6, "::1", port
58+
)
59+
)
5360
return is_open
5461

5562

‎packages/google-auth-oauthlib/tests/unit/test_flow.py‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,9 +572,13 @@ def test_exclusive_wsgi_server_bind_windows(self):
572572
):
573573
mock_socket.SOL_SOCKET = socket.SOL_SOCKET
574574
mock_socket.SO_EXCLUSIVEADDRUSE = getattr(socket, "SO_EXCLUSIVEADDRUSE", 1)
575+
mock_socket.AF_INET = socket.AF_INET
576+
mock_socket.AF_INET6 = socket.AF_INET6
577+
mock_socket.SOCK_STREAM = socket.SOCK_STREAM
578+
mock_socket.socket.return_value.__enter__.return_value.connect_ex.return_value = 1
575579

576580
server = flow._ExclusiveWSGIServer(
577-
("localhost", 0), flow._WSGIRequestHandler, bind_and_activate=False
581+
("localhost", 8085), flow._WSGIRequestHandler, bind_and_activate=False
578582
)
579583
server.socket = mock.Mock()
580584

@@ -583,6 +587,12 @@ def test_exclusive_wsgi_server_bind_windows(self):
583587
server.socket.setsockopt.assert_called_once_with(
584588
mock_socket.SOL_SOCKET, mock_socket.SO_EXCLUSIVEADDRUSE, 1
585589
)
590+
server._ipv6_socket.setsockopt.assert_called_once_with(
591+
mock_socket.SOL_SOCKET, mock_socket.SO_EXCLUSIVEADDRUSE, 1
592+
)
593+
server._ipv6_socket.bind.assert_called_once_with(("::1", 8085))
594+
server.server_close()
595+
assert server._ipv6_socket is None
586596

587597
def test_exclusive_wsgi_server_bind_non_windows(self):
588598
with mock.patch("sys.platform", "linux"):
@@ -594,3 +604,84 @@ def test_exclusive_wsgi_server_bind_non_windows(self):
594604
with mock.patch.object(wsgiref.simple_server.WSGIServer, "server_bind"):
595605
server.server_bind()
596606
server.socket.setsockopt.assert_not_called()
607+
server.server_close()
608+
609+
def test_exclusive_wsgi_server_detects_existing_listeners(self):
610+
with (
611+
mock.patch("sys.platform", "win32"),
612+
mock.patch("google_auth_oauthlib.flow.socket") as mock_socket,
613+
):
614+
mock_socket.SOL_SOCKET = socket.SOL_SOCKET
615+
mock_socket.SO_EXCLUSIVEADDRUSE = 1
616+
mock_socket.AF_INET = socket.AF_INET
617+
mock_socket.AF_INET6 = socket.AF_INET6
618+
mock_socket.SOCK_STREAM = socket.SOCK_STREAM
619+
620+
server = flow._ExclusiveWSGIServer(
621+
("localhost", 8085), flow._WSGIRequestHandler, bind_and_activate=False
622+
)
623+
server.socket = mock.Mock()
624+
625+
# 1. Pre-existing IPv6 listener on ::1 / [::]
626+
mock_socket.socket.return_value.__enter__.return_value.connect_ex.return_value = 0
627+
with mock.patch.object(wsgiref.simple_server.WSGIServer, "server_bind"):
628+
with pytest.raises(OSError):
629+
server.server_bind()
630+
631+
# 2. is_listener_present returns False when socket() raises OSError
632+
mock_socket.socket.side_effect = OSError("socket error")
633+
assert not flow._ExclusiveWSGIServer.is_listener_present(
634+
socket.AF_INET6, "::1", 8085
635+
)
636+
637+
def test_exclusive_wsgi_server_ipv4_literal_ignores_ipv6(self):
638+
# An address literal is served verbatim in redirect_uri, so the browser
639+
# never resolves `localhost` and `::1` is irrelevant.
640+
server = flow._ExclusiveWSGIServer(
641+
("127.0.0.1", 8085), flow._WSGIRequestHandler, bind_and_activate=False
642+
)
643+
server.socket = mock.Mock()
644+
645+
with (
646+
mock.patch.object(wsgiref.simple_server.WSGIServer, "server_bind"),
647+
mock.patch.object(
648+
flow._ExclusiveWSGIServer, "is_listener_present", return_value=True
649+
) as is_listener_present,
650+
):
651+
server.server_bind()
652+
653+
is_listener_present.assert_not_called()
654+
assert server._ipv6_socket is None
655+
656+
def test_exclusive_wsgi_server_ipv6_bind_errors(self):
657+
import errno
658+
659+
server = flow._ExclusiveWSGIServer(
660+
("localhost", 8085), flow._WSGIRequestHandler, bind_and_activate=False
661+
)
662+
server.socket = mock.Mock()
663+
664+
with (
665+
mock.patch.object(wsgiref.simple_server.WSGIServer, "server_bind"),
666+
mock.patch.object(
667+
flow._ExclusiveWSGIServer, "is_listener_present", return_value=False
668+
),
669+
mock.patch("google_auth_oauthlib.flow.socket") as mock_socket,
670+
):
671+
mock_socket.AF_INET6 = socket.AF_INET6
672+
mock_socket.SOCK_STREAM = socket.SOCK_STREAM
673+
674+
# 1. Ignored when socket(AF_INET6) itself raises EAFNOSUPPORT
675+
mock_socket.socket.side_effect = OSError(
676+
errno.EAFNOSUPPORT, "IPv6 disabled in kernel"
677+
)
678+
server.server_bind()
679+
assert server._ipv6_socket is None
680+
681+
# 2. Non-fatal when bind(("::1", ...)) fails but no listener is present
682+
mock_socket.socket.side_effect = None
683+
mock_socket.socket.return_value.bind.side_effect = OSError(
684+
errno.EADDRINUSE, "Held by non-listening socket"
685+
)
686+
server.server_bind()
687+
assert server._ipv6_socket is None

‎packages/google-auth-oauthlib/tests/unit/test_interactive.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,33 @@ def mock_socket(family, type_):
5555
socket_instance.listen.assert_has_calls(mock.call(1) for _ in range(100))
5656

5757

58+
def test_is_port_open_ipv6_checks(monkeypatch):
59+
import errno
60+
61+
from google_auth_oauthlib import interactive as module_under_test
62+
63+
sock4 = mock.create_autospec(socket.socket, instance=True)
64+
sock6 = mock.MagicMock()
65+
66+
def make_sock(family, type_):
67+
if family == socket.AF_INET6:
68+
return sock6
69+
return sock4
70+
71+
monkeypatch.setattr(socket, "socket", make_sock)
72+
73+
sock6.__enter__.return_value.connect_ex.return_value = 0
74+
assert not module_under_test.is_port_open(8085)
75+
76+
def mock_socket_fn(family, type_):
77+
if family == socket.AF_INET6:
78+
raise OSError(errno.EAFNOSUPPORT, "IPv6 disabled")
79+
return sock4
80+
81+
monkeypatch.setattr(socket, "socket", mock_socket_fn)
82+
assert module_under_test.is_port_open(8085)
83+
84+
5885
def test_get_user_credentials():
5986
from google_auth_oauthlib import flow
6087
from google_auth_oauthlib import interactive as module_under_test

0 commit comments

Comments
 (0)