fix(site): preserve cookies when redirecting to HTTPS

This commit is contained in:
jxxghp
2026-11-07 06:03:07 +08:00
parent 0851b373d2
commit 82e2ea52c9
2 changed files with 129 additions and 3 deletions

View File

@@ -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()

View File

@@ -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='<html><body><a href="logout.php">退出</a></body></html>'),
])
_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)], "站点请求重定向缺少目标地址"),