From 82e2ea52c954e4ffbb03eea89f85323ef3f74a47 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 7 Nov 2026 06:03:07 +0800 Subject: [PATCH] fix(site): preserve cookies when redirecting to HTTPS --- app/startup/composition/site.py | 35 +++++++++++- tests/test_site_chain_ports.py | 97 +++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/app/startup/composition/site.py b/app/startup/composition/site.py index ce2d9e2be..3ba87951e 100644 --- a/app/startup/composition/site.py +++ b/app/startup/composition/site.py @@ -68,6 +68,34 @@ def _redirect_destination(url: str, response: Any) -> tuple[Optional[str], Optio return destination, None +def _redirect_keeps_credentials(source: str, destination: str) -> bool: + """ + 判断站点重定向是否仍可携带原请求的登录凭据。 + + 除同源跳转外,仅允许同主机从默认 HTTP 端口升级到默认 HTTPS 端口, + 避免站点强制 HTTPS 时丢失 Cookie;协议降级和其它来源变更均不保留凭据。 + + :param source: 当前请求的绝对地址 + :param destination: 重定向目标的绝对地址 + :return: 两地址同源或符合默认端口的安全升级条件时返回 True + """ + if UrlUtils.same_origin(source, destination): + return True + source_url = UrlUtils.normalize_http_url(source) + destination_url = UrlUtils.normalize_http_url(destination) + if source_url is None or destination_url is None: + return False + origin, target = urlsplit(source_url), urlsplit(destination_url) + return ( + origin.scheme == "http" + and target.scheme == "https" + and origin.hostname == target.hostname + and origin.port in (None, 80) + and target.port in (None, 443) + and not (origin.username or origin.password or target.username or target.password) + ) + + class _RssHttpAdapter: """ 用 RequestUtils 实现 RSS HTTP 窄端口。 @@ -236,8 +264,9 @@ class _SiteHttpAdapter: """ 打开响应并在所有返回或异常分支后关闭底层连接。 - GET 请求逐跳跟随重定向,跨域跳转清除 Cookie 与认证头。重定向缺少目标、目标无效、出现环路 - 或超过跳数上限时只记录来源地址的告警并提供 None,与请求失败的语义一致, + GET 请求逐跳跟随重定向,同源或同主机默认端口的 HTTP 到 HTTPS 升级保留凭据, + 其它来源变更清除 Cookie 与认证头。重定向缺少目标、目标无效、出现环路或超过跳数上限时 + 只记录来源地址的告警并提供 None,与请求失败的语义一致, 由调用方按无法打开站点处理,不能以异常中断 CookieCloud 同步等批量流程。 :param method: HTTP 请求方法 @@ -281,7 +310,7 @@ class _SiteHttpAdapter: if destination is None: failure = invalid_reason or failure break - if not UrlUtils.same_origin(url, destination): + if not _redirect_keeps_credentials(url, destination): cookie = None request_headers = { key: value for key, value in request_headers.items() diff --git a/tests/test_site_chain_ports.py b/tests/test_site_chain_ports.py index 1b8e138dd..48d282522 100644 --- a/tests/test_site_chain_ports.py +++ b/tests/test_site_chain_ports.py @@ -480,6 +480,103 @@ def _scripted_request(monkeypatch, responses: list) -> list[dict]: return hops +@pytest.mark.parametrize("status", [301, 302, 303, 307, 308]) +@pytest.mark.parametrize("source,destination", [ + ("http://hdwing.org/", "https://hdwing.org/"), + ("http://hdwing.org:80/", "https://hdwing.org:443/index.php"), + ("http://HDWING.org/index.php", "https://hdwing.org/"), + ("http://hdwing.org/", "http://hdwing.org/index.php"), + ("https://hdwing.org/", "https://hdwing.org/index.php"), +]) +def test_site_adapter_keeps_credentials_on_same_origin_or_https_upgrade( + monkeypatch, status: int, source: str, destination: str, +) -> None: + """ + 同源跳转与同主机默认端口的 HTTPS 升级应保留 Cookie 和认证头。 + """ + final = _FakeResponse(200, text="ok") + hops = _scripted_request(monkeypatch, [ + SimpleNamespace(status_code=status, headers={"Location": destination}), final, + ]) + headers = {"Cookie": "sid=header", "Authorization": "Bearer test", "Referer": source} + + with site_composition._SiteHttpAdapter().open( + method="GET", url=source, cookie="uid=1", headers=headers, + ) as response: + assert response is final + + assert [hop["url"] for hop in hops] == [source, destination] + assert [hop["cookies"] for hop in hops] == ["uid=1", "uid=1"] + assert [hop["headers"] for hop in hops] == [headers, headers] + assert all(hop["allow_redirects"] is False for hop in hops) + + +@pytest.mark.parametrize("source,destination", [ + ("http://hdwing.org/", "https://other.test/"), + ("http://hdwing.org/", "https://www.hdwing.org/"), + ("http://hdwing.org:8080/", "https://hdwing.org/"), + ("http://hdwing.org/", "https://hdwing.org:8443/"), + ("http://hdwing.org:443/", "https://hdwing.org/"), + ("http://hdwing.org/", "https://hdwing.org:80/"), + ("https://hdwing.org/", "http://hdwing.org/"), + ("https://hdwing.org/", "https://hdwing.org:8443/"), +]) +def test_site_adapter_strips_credentials_when_redirect_changes_trust( + monkeypatch, source: str, destination: str, +) -> None: + """ + 跨主机、非默认端口升级与 HTTPS 降级仍应清除全部敏感请求头。 + """ + hops = _scripted_request(monkeypatch, [_redirect(destination), _FakeResponse(200)]) + with site_composition._SiteHttpAdapter().open( + method="GET", url=source, cookie="uid=1", + headers={ + "cOoKiE": "sid=header", "aUtHoRiZaTiOn": "Bearer test", + "Proxy-Authorization": "Basic test", "rEfErEr": source, "Accept": "text/html", + }, + ) as response: + assert response.status_code == 200 + + assert [hop["cookies"] for hop in hops] == ["uid=1", ""] + assert hops[1]["headers"] == {"Accept": "text/html"} + + +def test_site_adapter_does_not_restore_credentials_after_downgrade_then_upgrade(monkeypatch) -> None: + """ + HTTPS 降级清除的凭据,在后续同主机升级回 HTTPS 时不能重新出现。 + """ + hops = _scripted_request(monkeypatch, [ + _redirect("http://hdwing.org/next"), + _redirect("https://hdwing.org/final"), + _FakeResponse(200), + ]) + with site_composition._SiteHttpAdapter().open( + method="GET", url="https://hdwing.org/", cookie="uid=1", + headers={"Authorization": "Bearer test"}, + ) as response: + assert response.status_code == 200 + + assert [hop["cookies"] for hop in hops] == ["uid=1", "", ""] + assert [hop["headers"] for hop in hops] == [{"Authorization": "Bearer test"}, {}, {}] + + +def test_site_connection_keeps_cookie_when_hdwing_redirects_to_https(monkeypatch) -> None: + """ + HDWing 的 HTTP 入口升级到 HTTPS 后,连接测试应携带原 Cookie 并识别已登录页面。 + """ + hops = _scripted_request(monkeypatch, [ + _redirect("https://hdwing.org/"), + _FakeResponse(200, text='退出'), + ]) + _configure(site_composition._SiteHttpAdapter()) + site = SiteSnapshot( + id=1, name="瓷器", domain="hdwing.org", url="http://hdwing.org/", cookie="uid=1", + ) + + assert _chain()._SiteChain__test(site) == (True, "连接成功") + assert [hop["cookies"] for hop in hops] == ["uid=1", "uid=1"] + + @pytest.mark.parametrize("responses,reason", [ ([_redirect("/dl/1/auth?X-Amz-Signature=sig")] * 2, "站点请求重定向出现环路"), ([_redirect(None)], "站点请求重定向缺少目标地址"),