fix: prefer IPv4 for extension gallery downloads to prevent timeout on broken IPv6

Clean rebase of rumotoshino's #5458 (rebase-first).

Co-authored-by: rumotoshino <rumotoshino@users.noreply.github.com>
This commit is contained in:
nesquena-hermesandrumotoshino committed 2026-07-03 20:26:18 +00:00
1 parent 97821f5b3f
commit 5ea8b09b83
2 files changed
+176 -6

No files matched your search

+81 -3
View File
@@ -6,11 +6,13 @@ It is disabled by default and never executes or fetches third-party URLs.
""" """
import html import html
import http.client
import json import json
import math import math
import logging import logging
import os import os
import re import re
import socket
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple from typing import Any, Dict, List, Optional, Set, Tuple
@@ -19,7 +21,11 @@ import hashlib
import io import io
import time import time
import zipfile import zipfile
from urllib.request import HTTPRedirectHandler, build_opener, urlopen from urllib.request import (
HTTPRedirectHandler,
HTTPSHandler,
build_opener,
)
from api.helpers import _security_headers, j from api.helpers import _security_headers, j
@@ -108,9 +114,80 @@ class _AllowlistRedirectHandler(HTTPRedirectHandler):
return super().redirect_request(req, fp, code, msg, headers, newurl) return super().redirect_request(req, fp, code, msg, headers, newurl)
def _connect_ipv4_first(address, timeout=socket._GLOBAL_DEFAULT_TIMEOUT, source_address=None):
"""Connect to *address*, preferring IPv4 to avoid dead-route stalls.
Some networks advertise IPv6 routes (router advertisements, tunnel brokers)
that do not actually reach the public internet. ``socket.create_connection``
follows the OS address ordering, which typically tries IPv6 first; each dead
address stalls for the full TCP timeout (~75–130 s) before falling back to
IPv4. This makes gallery installs and registry fetches appear to hang until
the browser or curl timeout kills them.
We iterate families in IPv4→IPv6 order so the common case (IPv4 works, IPv6
is a broken route) completes instantly. IPv6 is still tried as a fallback so
IPv6-only networks are unaffected.
Signature matches ``socket.create_connection`` including the
``_GLOBAL_DEFAULT_TIMEOUT`` sentinel so callers that rely on the stdlib
default-timeout behaviour are not broken.
"""
host, port = address
if timeout is socket._GLOBAL_DEFAULT_TIMEOUT:
timeout = socket.getdefaulttimeout()
last_error: OSError | None = None
for family in (socket.AF_INET, socket.AF_INET6):
try:
infos = socket.getaddrinfo(host, port, family, socket.SOCK_STREAM)
except socket.gaierror:
continue
for _fam, socktype, proto, _canon, sockaddr in infos:
sock = None
try:
sock = socket.socket(_fam, socktype, proto)
if timeout is not None:
sock.settimeout(timeout)
if source_address is not None:
sock.bind(source_address)
sock.connect(sockaddr)
return sock
except OSError as exc:
last_error = exc
if sock is not None:
sock.close()
if last_error is not None:
raise last_error
raise OSError(f"Could not connect to {host}:{port}")
class _IPv4FirstHTTPSConnection(http.client.HTTPSConnection):
"""HTTPS connection that resolves IPv4 addresses before IPv6.
Overrides the class-level ``_create_connection`` that
``HTTPConnection.connect()`` calls — the stdlib extension point designed for
exactly this kind of per-class customisation (added in Python 3.9,
bpo-37830). No global state is mutated, so the change is thread-safe under
``ThreadingHTTPServer``.
"""
_create_connection = staticmethod(_connect_ipv4_first)
class _IPv4FirstHTTPSHandler(HTTPSHandler):
"""HTTPS handler that builds IPv4-first connections for gallery downloads."""
def https_open(self, req):
return self.do_open(_IPv4FirstHTTPSConnection, req)
def _build_gallery_opener():
"""Opener with IPv4-first downloads and redirect-host allowlist."""
return build_opener(_IPv4FirstHTTPSHandler, _AllowlistRedirectHandler)
def _safe_download(url: str, max_bytes: int, timeout: int = 30) -> bytes: def _safe_download(url: str, max_bytes: int, timeout: int = 30) -> bytes:
"""Download from an allowlisted host, rejecting cross-host redirects.""" """Download from an allowlisted host, rejecting cross-host redirects."""
opener = build_opener(_AllowlistRedirectHandler) opener = _build_gallery_opener()
resp = opener.open(url, timeout=timeout) resp = opener.open(url, timeout=timeout)
try: try:
return resp.read(max_bytes + 1) return resp.read(max_bytes + 1)
@@ -1813,7 +1890,8 @@ def get_extension_registry() -> Dict[str, Any]:
if cached is not None and (now - cached_at) < _REGISTRY_TTL_SECONDS: if cached is not None and (now - cached_at) < _REGISTRY_TTL_SECONDS:
return {"entries": cached} return {"entries": cached}
try: try:
raw = urlopen(_REGISTRY_URL, timeout=10).read(2 * 1024 * 1024) opener = _build_gallery_opener()
raw = opener.open(_REGISTRY_URL, timeout=10).read(2 * 1024 * 1024)
data = json.loads(raw.decode("utf-8")) data = json.loads(raw.decode("utf-8"))
if isinstance(data, list): if isinstance(data, list):
entries = data entries = data
+95 -3
View File
@@ -3,9 +3,10 @@
import hashlib import hashlib
import io import io
import json import json
import socket
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -419,7 +420,7 @@ def test_gallery_registry_list_format(monkeypatch, tmp_path):
mock_resp.read.return_value = json.dumps(registry_data).encode("utf-8") mock_resp.read.return_value = json.dumps(registry_data).encode("utf-8")
ext_mod._REGISTRY_CACHE.clear() ext_mod._REGISTRY_CACHE.clear()
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp) monkeypatch.setattr(ext_mod, "_build_gallery_opener", lambda: MagicMock(open=lambda *a, **kw: mock_resp))
result = ext_mod.get_extension_registry() result = ext_mod.get_extension_registry()
assert "entries" in result assert "entries" in result
@@ -444,7 +445,7 @@ def test_gallery_registry_extensions_format(monkeypatch, tmp_path):
mock_resp.read.return_value = json.dumps(registry_data).encode("utf-8") mock_resp.read.return_value = json.dumps(registry_data).encode("utf-8")
ext_mod._REGISTRY_CACHE.clear() ext_mod._REGISTRY_CACHE.clear()
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp) monkeypatch.setattr(ext_mod, "_build_gallery_opener", lambda: MagicMock(open=lambda *a, **kw: mock_resp))
result = ext_mod.get_extension_registry() result = ext_mod.get_extension_registry()
assert len(result["entries"]) == 1 assert len(result["entries"]) == 1
@@ -522,3 +523,94 @@ def test_install_rejects_redirect_to_disallowed_host(monkeypatch, tmp_path):
"https://hermes-webui.github.io/exts/redir-ext.zip", "https://hermes-webui.github.io/exts/redir-ext.zip",
"a" * 64, "a" * 64,
) )
def test_connect_ipv4_first_tries_inet_before_inet6(monkeypatch):
"""_connect_ipv4_first calls getaddrinfo with AF_INET before AF_INET6."""
import api.extensions as ext_mod
call_order = []
real_getaddrinfo = socket.getaddrinfo
def tracking_getaddrinfo(host, port, family, *args, **kwargs):
call_order.append(family)
return real_getaddrinfo(host, port, family, *args, **kwargs)
monkeypatch.setattr(socket, "getaddrinfo", tracking_getaddrinfo)
monkeypatch.setattr(socket, "getdefaulttimeout", lambda: None)
# We don't actually need to connect; just verify family ordering.
try:
ext_mod._connect_ipv4_first(("localhost", 80), timeout=None)
except OSError:
pass # connection failure is fine — we only care about call order
assert socket.AF_INET in call_order
if socket.AF_INET6 in call_order:
assert call_order.index(socket.AF_INET) < call_order.index(socket.AF_INET6)
def test_connect_ipv4_first_handles_global_default_timeout():
"""The _GLOBAL_DEFAULT_TIMEOUT sentinel is resolved correctly."""
import api.extensions as ext_mod
# Should not raise TypeError when passed the stdlib sentinel.
sentinel = socket._GLOBAL_DEFAULT_TIMEOUT
# Force default timeout to None so no actual timeout is set on the socket.
with patch.object(socket, "getdefaulttimeout", return_value=None):
try:
ext_mod._connect_ipv4_first(("127.0.0.1", 1), timeout=sentinel)
except OSError:
pass # connection refused is fine
except TypeError:
pytest.fail("TypeError — sentinel not resolved")
def test_gallery_opener_includes_ipv4_first_handler():
"""_build_gallery_opener produces an opener wired to the IPv4-first HTTPS handler."""
import api.extensions as ext_mod
opener = ext_mod._build_gallery_opener()
# build_opener instantiates handler classes internally; find the one whose
# https_open uses our IPv4-first connection class.
handler_classes = [type(h) for h in opener.handlers]
assert ext_mod._IPv4FirstHTTPSHandler in handler_classes
def test_safe_download_uses_gallery_opener(monkeypatch):
"""_safe_download routes through _build_gallery_opener (which is IPv4-first)."""
import api.extensions as ext_mod
fake_response = MagicMock()
fake_response.read.return_value = b"file-content"
fake_response.close = MagicMock()
fake_opener = MagicMock(open=lambda url, timeout=None: fake_response)
called = []
monkeypatch.setattr(ext_mod, "_build_gallery_opener", lambda: (called.append(True), fake_opener)[1])
result = ext_mod._safe_download("https://example.com/x.zip", 1024)
assert result == b"file-content"
assert called == [True]
def test_registry_fetch_uses_gallery_opener(monkeypatch):
"""get_extension_registry routes through _build_gallery_opener (IPv4-first)."""
import api.extensions as ext_mod
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(
{"extensions": [{"id": "test-ext", "name": "Test"}]}
).encode("utf-8")
ext_mod._REGISTRY_CACHE.clear()
called = []
fake_opener = MagicMock(open=lambda *a, **kw: mock_resp)
monkeypatch.setattr(ext_mod, "_build_gallery_opener", lambda: (called.append(True), fake_opener)[1])
result = ext_mod.get_extension_registry()
assert len(result["entries"]) == 1
assert result["entries"][0]["id"] == "test-ext"
assert called == [True]