100 lines
3.9 KiB
Python
100 lines
3.9 KiB
Python
"""Extracted from octo_updater.py. Keep this module focused on its named responsibility."""
|
|
import json
|
|
import hashlib
|
|
import os
|
|
import sys
|
|
import ssl
|
|
import re
|
|
import subprocess
|
|
import urllib.request
|
|
from urllib.parse import urlsplit
|
|
import shutil
|
|
import stat
|
|
import struct
|
|
import time
|
|
import math
|
|
import threading
|
|
import queue
|
|
from functools import cache
|
|
from pathlib import Path
|
|
|
|
from config import UA
|
|
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Secure networking
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
# Hardened TLS: verify the server certificate against the system trust store,
|
|
# require the hostname to match, and refuse anything below TLS 1.2. This is
|
|
# the primary defence against a man-in-the-middle tampering with downloads.
|
|
SSL_CTX = ssl.create_default_context()
|
|
SSL_CTX.check_hostname = True
|
|
SSL_CTX.verify_mode = ssl.CERT_REQUIRED
|
|
# Trust certifi's curated roots *in addition to* the system store, so a stale
|
|
# or incomplete Windows root store (Python's ssl uses a static snapshot and
|
|
# never triggers Windows' on-demand root update) can't break verification.
|
|
# If certifi isn't bundled, fall back to the system store alone.
|
|
try:
|
|
import certifi
|
|
SSL_CTX.load_verify_locations(certifi.where())
|
|
except Exception:
|
|
pass
|
|
try:
|
|
SSL_CTX.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
except (AttributeError, ValueError):
|
|
pass
|
|
|
|
# Binaries may only be fetched from these hosts. TLS already stops a MITM from
|
|
# impersonating them; this additionally stops a tampered API response from
|
|
# redirecting a download (e.g. a mod DLL) to an unexpected host.
|
|
ALLOWED_DOWNLOAD_HOSTS = {
|
|
"octowow.st",
|
|
"dl.octowow.st",
|
|
"github.com",
|
|
"raw.githubusercontent.com",
|
|
"objects.githubusercontent.com",
|
|
"release-assets.githubusercontent.com",
|
|
"codeberg.org",
|
|
}
|
|
|
|
|
|
def _check_url(url: str, allowed_hosts):
|
|
"""Enforce HTTPS and (optionally) an allowlist on a URL."""
|
|
parts = urlsplit(url)
|
|
if parts.scheme != "https":
|
|
raise RuntimeError(f"Refusing non-HTTPS URL: {url}")
|
|
if allowed_hosts is not None:
|
|
host = (parts.hostname or "").lower()
|
|
if host not in allowed_hosts:
|
|
raise RuntimeError(f"Refusing download from unexpected host: {host}")
|
|
|
|
|
|
class _HttpsOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
"""Require every redirect target to stay HTTPS (blocks an https→http
|
|
downgrade). The host allowlist is deliberately *not* re-applied on
|
|
redirects: an allowlisted host controls its own redirects — legitimately
|
|
to its CDN (e.g. octowow.st→dl.octowow.st, github.com→codeload) — and TLS
|
|
protects wherever it lands. The allowlist's job is to vet the *initial*
|
|
URL (against a tampered API response), which secure_urlopen still does."""
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
_check_url(newurl, None) # HTTPS-only, no host check
|
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
|
|
|
|
# Shared opener with the hardened TLS context and the HTTPS-only redirect
|
|
# guard, built once.
|
|
_SECURE_OPENER = urllib.request.build_opener(
|
|
urllib.request.HTTPSHandler(context=SSL_CTX),
|
|
_HttpsOnlyRedirectHandler())
|
|
|
|
|
|
def secure_urlopen(req, timeout, allowed_hosts=None):
|
|
"""urlopen wrapper that enforces HTTPS + an optional host allowlist on the
|
|
initial URL, keeps redirects on HTTPS, and uses the hardened TLS context.
|
|
`req` may be a URL string or a urllib Request."""
|
|
url = req.full_url if isinstance(req, urllib.request.Request) else req
|
|
_check_url(url, allowed_hosts)
|
|
return _SECURE_OPENER.open(req, timeout=timeout)
|