feat(update): support verified public HTTP downloads
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user