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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user