feat(update-service): SSRF-validated redirects + restart hardening

update_service grows explicit URL validation on the redirect chain so a
hostile mirror can't bounce the updater to a private IP. restart.ps1
gets stricter argument handling and clearer log lines.
default_config.yaml exposes the new toggles. test_system_routes pins
the new behaviour.
This commit is contained in:
2026-05-23 00:49:18 +03:00
parent 826e680f37
commit 45d12b2811
4 changed files with 217 additions and 62 deletions
+102 -28
View File
@@ -5,11 +5,13 @@ import hashlib
import os
import re
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import httpx
@@ -23,6 +25,7 @@ from ledgrab.core.update.release_provider import AssetInfo, ReleaseInfo, Release
from ledgrab.core.update.version_check import is_newer, normalize_version
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
from ledgrab.utils.net_classify import is_blocked_for_ssrf
logger = get_logger(__name__)
@@ -38,6 +41,44 @@ _SHA256_RE = re.compile(r"\b([a-fA-F0-9]{64})\b")
_STARTUP_DELAY_S = 30
_MANUAL_CHECK_DEBOUNCE_S = 60
# Manual-redirect limits for SSRF-safe update downloads.
_UPDATE_MAX_REDIRECT_HOPS = 5
def _validate_update_url(url: str) -> None:
"""Reject update URLs whose scheme or resolved host is non-public.
The update pipeline fetches release feeds and binaries from
``update.repo_url`` (default: Gitea instance) and may follow
redirects to CDN hosts. Without per-hop validation, a hostile or
compromised feed could redirect the binary download to a private
address (SSRF) or to a non-HTTPS scheme. This guard enforces:
* scheme is ``http`` or ``https``
* hostname is present
* DNS resolution returns no private / loopback / link-local /
multicast / reserved / unparseable address
Raises ``RuntimeError`` (not ``HTTPException`` — this code path runs
in a background task, not a request handler).
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise RuntimeError(f"Refusing update URL with unsupported scheme: {parsed.scheme!r}")
hostname = parsed.hostname
if not hostname:
raise RuntimeError("Update URL missing hostname")
try:
infos = socket.getaddrinfo(hostname, None)
except socket.gaierror as exc:
raise RuntimeError(f"Cannot resolve update host: {hostname} ({exc})") from exc
ips = {info[4][0] for info in infos}
for ip in ips:
if is_blocked_for_ssrf(ip):
raise RuntimeError(
f"Refusing update URL: host {hostname!r} resolves to " f"non-public address {ip}"
)
class UpdateService:
"""Periodically polls a ReleaseProvider and fires WebSocket events."""
@@ -250,29 +291,64 @@ class UpdateService:
finally:
self._downloading = False
async def _safe_get_text(self, url: str, timeout: float = 30.0) -> str:
"""Fetch *url* as text with manual, SSRF-validated redirect handling."""
current = url
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
for _ in range(_UPDATE_MAX_REDIRECT_HOPS + 1):
_validate_update_url(current)
resp = await client.get(current)
if resp.is_redirect:
location = resp.headers.get("location")
if not location:
raise RuntimeError("Update redirect without Location header")
current = str(httpx.URL(current).join(location))
continue
resp.raise_for_status()
return resp.text
raise RuntimeError(
f"Too many redirects fetching update text (max {_UPDATE_MAX_REDIRECT_HOPS})"
)
async def _stream_download(self, url: str, dest: Path, total_size: int) -> None:
"""Stream-download a file, updating progress as we go."""
"""Stream-download a file with manual, SSRF-validated redirect handling.
Each hop is re-validated via :func:`_validate_update_url` so a
compromised release feed cannot redirect the binary download to a
non-public address.
"""
tmp = dest.with_suffix(dest.suffix + ".tmp")
received = 0
async with httpx.AsyncClient(timeout=300, follow_redirects=True) as client:
async with client.stream("GET", url) as resp:
resp.raise_for_status()
with open(tmp, "wb") as f:
async for chunk in resp.aiter_bytes(chunk_size=65536):
f.write(chunk)
received += len(chunk)
if total_size > 0:
self._download_progress = received / total_size
if self._fire_event:
self._fire_event(
{
"type": "update_download_progress",
"progress": round(self._download_progress, 3),
}
)
# Atomic rename
tmp.replace(dest)
self._download_progress = 1.0
current = url
async with httpx.AsyncClient(timeout=300, follow_redirects=False) as client:
for _ in range(_UPDATE_MAX_REDIRECT_HOPS + 1):
_validate_update_url(current)
async with client.stream("GET", current) as resp:
if resp.is_redirect:
location = resp.headers.get("location")
if not location:
raise RuntimeError("Update redirect without Location header")
current = str(httpx.URL(current).join(location))
continue
resp.raise_for_status()
with open(tmp, "wb") as f:
async for chunk in resp.aiter_bytes(chunk_size=65536):
f.write(chunk)
received += len(chunk)
if total_size > 0:
self._download_progress = received / total_size
if self._fire_event:
self._fire_event(
{
"type": "update_download_progress",
"progress": round(self._download_progress, 3),
}
)
# Atomic rename
tmp.replace(dest)
self._download_progress = 1.0
return
raise RuntimeError(f"Too many redirects fetching update (max {_UPDATE_MAX_REDIRECT_HOPS})")
# ── Apply ──────────────────────────────────────────────────
@@ -324,20 +400,18 @@ class UpdateService:
if not asset:
return None
# 1) sibling .sha256 asset
# 1) sibling .sha256 asset — fetch with manual, SSRF-validated
# redirects so the checksum can't be sourced from an untrusted host.
sibling = next(
(a for a in release.assets if a.name == f"{asset.name}.sha256"),
None,
)
if sibling:
try:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
resp = await client.get(sibling.download_url)
resp.raise_for_status()
text = resp.text.strip()
match = _SHA256_RE.search(text)
if match:
return match.group(1).lower()
text = await self._safe_get_text(sibling.download_url)
match = _SHA256_RE.search(text.strip())
if match:
return match.group(1).lower()
except Exception as exc:
logger.warning("Failed to fetch sibling sha256 asset: %s", exc)