feat(update): support verified public HTTP downloads

This commit is contained in:
chengma
2026-07-13 16:19:03 +08:00
parent 9a458ee195
commit 74b98cd123
8 changed files with 234 additions and 37 deletions
+7
View File
@@ -117,6 +117,13 @@ class ForcedUpdateDialog(QDialog):
self.details_label = QLabel(details)
self.details_label.setWordWrap(True)
layout.addWidget(self.details_label)
self.http_warning_label = QLabel("当前使用临时 HTTP 通道下载,安装前仍会校验完整性")
self.http_warning_label.setWordWrap(True)
self.http_warning_label.setStyleSheet("color: #b45309;")
self.http_warning_label.setHidden(
not update_installer.uses_plain_http(self.result.download_url)
)
layout.addWidget(self.http_warning_label)
self.stage_label = QLabel("发现必须升级的新版本")
layout.addWidget(self.stage_label)
self.progress_bar = QProgressBar()
+64 -21
View File
@@ -8,6 +8,7 @@ import json
import os
import re
import shutil
import socket
import stat
import urllib.parse
import urllib.request
@@ -30,7 +31,6 @@ MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024
MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024
MAX_FILE_COUNT = 20000
MAX_COMPRESSION_RATIO = 200
DEFAULT_TRUSTED_HOSTS = frozenset({"cm.833729.com"})
RESERVED_NAMES = {
"CON",
"PRN",
@@ -83,10 +83,10 @@ def metadata_from_update_info(info):
return metadata
def validate_metadata(metadata, trusted_hosts=DEFAULT_TRUSTED_HOSTS):
def validate_metadata(metadata):
if not re.fullmatch(r"\d+(?:\.\d+)*", metadata.version):
raise UpdateInstallError("新版版本号格式不正确")
validate_download_url(metadata.download_url, trusted_hosts)
validate_download_url(metadata.download_url)
if not re.fullmatch(r"[0-9a-fA-F]{64}", metadata.sha256):
raise UpdateInstallError("新版安装包校验值缺失或格式不正确")
if metadata.size_bytes <= 0 or metadata.size_bytes > MAX_DOWNLOAD_BYTES:
@@ -99,32 +99,74 @@ def validate_metadata(metadata, trusted_hosts=DEFAULT_TRUSTED_HOSTS):
raise UpdateInstallError("当前更新器版本过低")
def validate_download_url(url, trusted_hosts=DEFAULT_TRUSTED_HOSTS):
parsed = urllib.parse.urlsplit(str(url or ""))
def _system_resolve_host(host, port):
try:
answers = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except OSError as exc:
raise UpdateInstallError("新版下载地址无法解析") from exc
return [answer[4][0] for answer in answers if answer[4]]
def _validate_public_addresses(addresses):
addresses = list(addresses or ())
if not addresses:
raise UpdateInstallError("新版下载地址无法解析")
for value in addresses:
try:
address = ipaddress.ip_address(str(value).split("%", 1)[0])
except ValueError as exc:
raise UpdateInstallError("新版下载地址解析结果无效") from exc
if not address.is_global:
raise UpdateInstallError("新版下载地址不能指向本机或内网")
def uses_plain_http(url):
try:
return urllib.parse.urlsplit(str(url or "")).scheme.lower() == "http"
except ValueError:
return False
def validate_download_url(url, *, resolver=None):
try:
parsed = urllib.parse.urlsplit(str(url or ""))
except ValueError as exc:
raise UpdateInstallError("新版下载地址格式不正确") from exc
host = (parsed.hostname or "").lower().rstrip(".")
trusted = {value.lower().rstrip(".") for value in trusted_hosts}
if parsed.scheme.lower() != "https" or not host:
raise UpdateInstallError("新版下载地址必须使用 HTTPS")
if parsed.scheme.lower() not in {"http", "https"} or not host:
raise UpdateInstallError("新版下载地址必须使用 HTTP 或 HTTPS")
if parsed.username or parsed.password:
raise UpdateInstallError("新版下载地址不能包含账号信息")
if host == "localhost" or host.endswith(".localhost") or host == "localhost.localdomain":
raise UpdateInstallError("新版下载地址不能指向本机或内网")
try:
port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
except ValueError as exc:
raise UpdateInstallError("新版下载地址端口无效") from exc
try:
address = ipaddress.ip_address(host)
except ValueError:
address = None
if address is not None and not address.is_global:
raise UpdateInstallError("新版下载地址不能指向内网")
if host not in trusted:
raise UpdateInstallError("新版下载地址不在受信任域名内")
if address is not None:
_validate_public_addresses((address,))
elif resolver is not None:
try:
addresses = resolver(host, port)
except UpdateInstallError:
raise
except Exception as exc:
raise UpdateInstallError("新版下载地址无法解析") from exc
_validate_public_addresses(addresses)
return parsed
class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, trusted_hosts):
def __init__(self, resolver):
super().__init__()
self.trusted_hosts = trusted_hosts
self.resolver = resolver
def redirect_request(self, req, fp, code, msg, headers, newurl):
validate_download_url(newurl, self.trusted_hosts)
validate_download_url(newurl, resolver=self.resolver)
return super().redirect_request(req, fp, code, msg, headers, newurl)
@@ -143,14 +185,15 @@ def download_package(
metadata,
install_root,
*,
trusted_hosts=DEFAULT_TRUSTED_HOSTS,
resolver=None,
opener=None,
cancelled=None,
progress=None,
timeout=66,
):
validate_metadata(metadata, trusted_hosts)
validate_download_url(metadata.download_url, trusted_hosts)
validate_metadata(metadata)
resolver = resolver or _system_resolve_host
validate_download_url(metadata.download_url, resolver=resolver)
downloads = _update_root(install_root) / "downloads"
downloads.mkdir(parents=True, exist_ok=True)
part_path = downloads / (metadata.version + ".zip.part")
@@ -165,13 +208,13 @@ def download_package(
headers={"Accept": "application/zip", "User-Agent": "cmshopee-updater/1"},
)
if opener is None:
opener = urllib.request.build_opener(_SafeRedirectHandler(trusted_hosts))
opener = urllib.request.build_opener(_SafeRedirectHandler(resolver))
digest = hashlib.sha256()
downloaded = 0
try:
with opener.open(request, timeout=timeout) as response, part_path.open("wb") as output:
validate_download_url(response.geturl(), trusted_hosts)
validate_download_url(response.geturl(), resolver=resolver)
while True:
if cancelled and cancelled():
raise UpdateCancelled("已取消下载新版")
@@ -384,7 +427,7 @@ def load_verified_pending(install_root, metadata):
def prepare_update(metadata, install_root, stage_callback=None, **download_kwargs):
validate_metadata(metadata, download_kwargs.get("trusted_hosts", DEFAULT_TRUSTED_HOSTS))
validate_metadata(metadata)
if stage_callback:
stage_callback("正在检查已下载的新版")
reusable = load_verified_pending(install_root, metadata)