mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-02 18:17:11 +09:00
GitHub release resolution and the SSRF-guarded download (redirect re-validation, byte caps, token scoping) were 250 lines of plugin_service unrelated to installing. plugin_service re-exports the names its own code calls, so existing monkeypatches on it still land; tests that exercised the fetch internals directly now target plugin_fetch.
266 lines
10 KiB
Python
266 lines
10 KiB
Python
"""Fetching extension zips: GitHub URL resolution and SSRF-guarded downloads.
|
|
|
|
Re-exported by plugin_service, which is what callers import."""
|
|
import io
|
|
import logging
|
|
import os
|
|
import re
|
|
|
|
import requests
|
|
|
|
from app.utils.env import env_bool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _github_token():
|
|
"""Optional GitHub token (SERVERKIT_GITHUB_TOKEN). Lifts the 60/hr anonymous
|
|
rate limit and enables private-repo installs. Never logged or echoed back."""
|
|
return (os.environ.get('SERVERKIT_GITHUB_TOKEN') or '').strip()
|
|
|
|
|
|
def _is_github_host(url):
|
|
"""True for github.com / api.github.com / codeload.github.com URLs — the only
|
|
hosts the token is ever attached to (never leak it to third-party mirrors,
|
|
and never over plain http — audit L6)."""
|
|
return bool(re.match(r'^https://(?:[a-z0-9-]+\.)?github\.com/', url or '', re.I))
|
|
|
|
|
|
def _github_api_headers(accept='application/vnd.github+json'):
|
|
headers = {'Accept': accept, 'User-Agent': 'ServerKit-Plugin-Installer/1.0'}
|
|
token = _github_token()
|
|
if token:
|
|
headers['Authorization'] = f'Bearer {token}'
|
|
return headers
|
|
|
|
|
|
class GitHubResolveError(ValueError):
|
|
"""Actionable failure resolving a GitHub URL (rate limit, 404, private)."""
|
|
|
|
|
|
def _normalize_source_url(url):
|
|
"""Accept `owner/repo` and `owner/repo@tag` shorthand, normalizing to a
|
|
github.com URL. Full URLs and direct .zip links pass through unchanged."""
|
|
if not url:
|
|
return url
|
|
u = url.strip()
|
|
if u.startswith('http://') or u.startswith('https://') or u.endswith('.zip'):
|
|
return u
|
|
m = re.match(r'^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:@(.+))?$', u)
|
|
if not m:
|
|
return u
|
|
owner, repo, tag = m.groups()
|
|
if tag:
|
|
return f'https://github.com/{owner}/{repo}/releases/tag/{tag}'
|
|
return f'https://github.com/{owner}/{repo}'
|
|
|
|
|
|
def _resolve_github_url(url, strict=False):
|
|
"""Convert a GitHub repo URL to the latest release zip download URL.
|
|
|
|
Handles:
|
|
- https://github.com/user/repo -> latest release zip
|
|
- https://github.com/user/repo/releases/tag/v1.0.0 -> that release zip
|
|
- Direct zip URLs pass through unchanged
|
|
|
|
When ``strict`` is False (installs) any API hiccup silently falls back to the
|
|
default-branch/tag zipball. When ``strict`` is True (preview) failures raise
|
|
:class:`GitHubResolveError` with an actionable message, and a
|
|
``.used_zipball_fallback`` flag records whether no release asset was found.
|
|
"""
|
|
if url.endswith('.zip'):
|
|
return url
|
|
|
|
# Match github.com/owner/repo patterns
|
|
gh_match = re.match(
|
|
r'https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/releases/tag/([^/]+))?/?$',
|
|
url,
|
|
)
|
|
if not gh_match:
|
|
return url
|
|
|
|
owner, repo, tag = gh_match.groups()
|
|
|
|
if tag:
|
|
# Specific release - get its assets
|
|
api_url = f'https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}'
|
|
else:
|
|
# Latest release
|
|
api_url = f'https://api.github.com/repos/{owner}/{repo}/releases/latest'
|
|
|
|
try:
|
|
resp = requests.get(api_url, timeout=15, headers=_github_api_headers())
|
|
if strict:
|
|
_raise_for_github_status(resp, owner, repo)
|
|
resp.raise_for_status()
|
|
release = resp.json()
|
|
|
|
# Look for a .zip asset (prefer plugin zip over source)
|
|
for asset in release.get('assets', []):
|
|
if asset['name'].endswith('.zip'):
|
|
return asset['browser_download_url']
|
|
|
|
# No .zip asset — fall back to source zipball (default branch of the tag).
|
|
zipball = release.get('zipball_url') or (
|
|
f'https://api.github.com/repos/{owner}/{repo}/zipball/{tag}' if tag
|
|
else f'https://api.github.com/repos/{owner}/{repo}/zipball')
|
|
_mark_zipball_fallback(zipball)
|
|
return zipball
|
|
except GitHubResolveError:
|
|
raise
|
|
except Exception as e:
|
|
logger.warning(f'Could not resolve GitHub release URL: {e}')
|
|
# Fallback: try the zipball endpoint directly (no release published).
|
|
zipball = (f'https://api.github.com/repos/{owner}/{repo}/zipball/{tag}' if tag
|
|
else f'https://api.github.com/repos/{owner}/{repo}/zipball')
|
|
_mark_zipball_fallback(zipball)
|
|
return zipball
|
|
|
|
|
|
# Records the most recent resolution's zipball fallback so the preview flow can
|
|
# warn "no release found (installing the default-branch zipball)". Only read
|
|
# immediately after a _resolve_github_url call.
|
|
_LAST_RESOLUTION = {'zipball_fallback': False}
|
|
|
|
|
|
def _mark_zipball_fallback(_url):
|
|
_LAST_RESOLUTION['zipball_fallback'] = True
|
|
|
|
|
|
def _raise_for_github_status(resp, owner, repo):
|
|
"""Turn a GitHub API error response into an actionable GitHubResolveError."""
|
|
if resp.status_code == 200:
|
|
_LAST_RESOLUTION['zipball_fallback'] = False
|
|
return
|
|
if resp.status_code == 403 and resp.headers.get('X-RateLimit-Remaining') == '0':
|
|
reset = resp.headers.get('X-RateLimit-Reset')
|
|
mins = ''
|
|
try:
|
|
import time as _t
|
|
if reset:
|
|
mins = f' (try again in ~{max(1, round((int(reset) - _t.time()) / 60))} min)'
|
|
except Exception:
|
|
pass
|
|
raise GitHubResolveError(
|
|
f'GitHub API rate limit reached{mins}. Set SERVERKIT_GITHUB_TOKEN '
|
|
f'to raise the limit and enable private-repo installs.')
|
|
if resp.status_code == 404:
|
|
raise GitHubResolveError(
|
|
f'Repository or release not found for {owner}/{repo}. Check the URL, '
|
|
f'or set SERVERKIT_GITHUB_TOKEN if the repository is private.')
|
|
raise GitHubResolveError(
|
|
f'GitHub API returned HTTP {resp.status_code} for {owner}/{repo}.')
|
|
|
|
|
|
def _download_zip(url):
|
|
"""Download a zip file from URL and return bytes."""
|
|
resolved = _resolve_github_url(url)
|
|
return _download_resolved(resolved)
|
|
|
|
|
|
# ── Download safety (audit L4): scheme/host checks, redirect re-validation,
|
|
# and byte caps, shared by the zip and detached-signature fetches.
|
|
_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024 # extension zip cap
|
|
_MAX_REDIRECTS = 5
|
|
|
|
|
|
def _ip_is_public(ip):
|
|
import ipaddress
|
|
addr = ipaddress.ip_address(ip)
|
|
return not (addr.is_private or addr.is_loopback or addr.is_link_local
|
|
or addr.is_multicast or addr.is_reserved or addr.is_unspecified)
|
|
|
|
|
|
def _assert_url_safe(url):
|
|
"""Reject anything that isn't an https URL to a public host (SSRF guard).
|
|
|
|
Rules: https everywhere; http only for loopback dev hosts
|
|
(http://localhost:8000/ext.zip while developing); loopback targets are
|
|
always allowed (a same-host registry is legitimate), but RFC1918 /
|
|
link-local / reserved targets are refused by default — a panel must never
|
|
follow an attacker-supplied URL into the internal network or the cloud
|
|
metadata service. Operators with a private-zone registry can opt out with
|
|
SERVERKIT_ALLOW_PRIVATE_DOWNLOADS=1.
|
|
"""
|
|
import ipaddress
|
|
import socket
|
|
from urllib.parse import urlparse
|
|
|
|
parsed = urlparse(url or '')
|
|
scheme = (parsed.scheme or '').lower()
|
|
if scheme not in ('http', 'https'):
|
|
raise ValueError(
|
|
f"Unsupported URL scheme {scheme!r} — extension downloads must be https://")
|
|
host = parsed.hostname
|
|
if not host:
|
|
raise ValueError('Download URL has no host')
|
|
try:
|
|
ips = {info[4][0] for info in socket.getaddrinfo(host, None)}
|
|
except Exception as e:
|
|
raise ValueError(f'Could not resolve download host {host!r}: {e}')
|
|
if all(ipaddress.ip_address(ip).is_loopback for ip in ips):
|
|
return # loopback dev host — http or https both fine
|
|
if scheme == 'http':
|
|
raise ValueError(
|
|
'Plain-http extension downloads are only allowed for loopback dev hosts')
|
|
if env_bool('SERVERKIT_ALLOW_PRIVATE_DOWNLOADS'):
|
|
return
|
|
bad = sorted(ip for ip in ips if not _ip_is_public(ip))
|
|
if bad:
|
|
raise ValueError(
|
|
f'Download host {host!r} resolves to a non-public address ({bad[0]}) '
|
|
f'— refusing to fetch (SSRF guard). Set '
|
|
f'SERVERKIT_ALLOW_PRIVATE_DOWNLOADS=1 to allow private-zone registries.')
|
|
|
|
|
|
def _safe_get(url, timeout, headers, max_bytes):
|
|
"""GET ``url`` with per-hop SSRF re-validation (redirects are followed
|
|
manually so each hop is re-checked), a hard byte cap, and the GitHub
|
|
token re-computed per hop (never leaked cross-host). Returns bytes."""
|
|
from urllib.parse import urljoin
|
|
current = url
|
|
for _ in range(_MAX_REDIRECTS + 1):
|
|
_assert_url_safe(current)
|
|
hop_headers = dict(headers or {})
|
|
token = _github_token()
|
|
if token and _is_github_host(current):
|
|
hop_headers['Authorization'] = f'Bearer {token}'
|
|
resp = requests.get(current, timeout=timeout, stream=True,
|
|
headers=hop_headers, allow_redirects=False)
|
|
try:
|
|
# status_code check rather than is_redirect: equivalent semantics,
|
|
# and tolerant of minimal test doubles.
|
|
if resp.status_code in (301, 302, 303, 307, 308):
|
|
location = resp.headers.get('Location')
|
|
if not location:
|
|
raise ValueError('Redirect without a Location header')
|
|
current = urljoin(current, location)
|
|
continue
|
|
resp.raise_for_status()
|
|
buf = io.BytesIO()
|
|
for chunk in resp.iter_content(chunk_size=8192):
|
|
buf.write(chunk)
|
|
if buf.tell() > max_bytes:
|
|
raise ValueError(
|
|
f'Download exceeds the {max_bytes // (1024 * 1024)} MB cap '
|
|
f'— aborting (possible zip bomb or misconfigured source)')
|
|
buf.seek(0)
|
|
return buf.getvalue()
|
|
finally:
|
|
close = getattr(resp, 'close', None)
|
|
if callable(close):
|
|
close()
|
|
raise ValueError(f'Too many redirects fetching {url}')
|
|
|
|
|
|
def _download_resolved(resolved):
|
|
"""Download an already-resolved zip URL and return a BytesIO buffer."""
|
|
logger.info(f'Downloading plugin from: {resolved}')
|
|
headers = {
|
|
'Accept': 'application/octet-stream',
|
|
'User-Agent': 'ServerKit-Plugin-Installer/1.0',
|
|
}
|
|
data = _safe_get(resolved, timeout=120, headers=headers,
|
|
max_bytes=_MAX_DOWNLOAD_BYTES)
|
|
return io.BytesIO(data)
|