mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-18 08:35:28 +00:00
520b69a1c6
Addresses three reported advisories, all verified against the code. Fixes are
entirely server-side — deployed agents already send a valid X-API-Key on every
call, so enforcing it does not require any agent-script change or upgrade.
GHSA-7rhv-c5pc-69r8 (CRITICAL RCE — agent script-template poisoning):
- POST/GET /api/agents/script-templates/{platform} now require the agents.version
permission (was authentication-only), matching POST /versions. Blocks a viewer
JWT from overwriting the root install/upgrade script.
GHSA-3p5c-m5m4-mjpx (missing authentication):
- Agent data-plane endpoints now REQUIRE a valid X-API-Key (was optional/skipped
when the header was absent), checked before any DB access: config,
ssl-certificates (private keys!), upgrade-status, heartbeat (by-name and the
previously auth-less by-id), configuration pending-requests. Removes keyless
heartbeat spoofing and keyless rogue-agent auto-registration.
- Operator/UI endpoints now require a JWT: GET /api/agents, the entire
/api/dashboard-stats router, /api/health/{deep,agents,clusters}, and
/api/ssl/certificates/{id}/config-versions. The simple /api/health liveness
probe stays public. Adds shared auth_middleware.require_authenticated_user.
GHSA-3vh4-gvxx-wm2p (SSRF via ACME directory_url):
- New utils/ssrf_guard.py (https-only + public-IP-only, IPv4-pinned, no redirects),
applied to settings test-connection, acme_service.get_directory and
_signed_request, and validated at Let's Encrypt account creation. The
test-connection response no longer reflects arbitrary upstream JSON keys
(information-disclosure oracle) — only fixed ACME field names.
Verified: full pytest tests/ (1128 passed, 0 failed) + live localtest stack smoke
(valid JWT/key paths return 200/404 as expected; anonymous requests 401; SSRF to
metadata/private/loopback refused). No changes to backend/utils/agent_scripts/*.
117 lines
4.6 KiB
Python
117 lines
4.6 KiB
Python
"""
|
|
SSRF guard for outbound HTTP fetches to user/DB-controlled URLs.
|
|
|
|
GHSA-3vh4-gvxx-wm2p: the ACME `directory_url` was fetched server-side with no
|
|
validation, turning the backend into a request-forwarding primitive against
|
|
loopback / RFC1918 / link-local / cloud-metadata IP space (and reflecting the
|
|
upstream JSON keys back to the caller).
|
|
|
|
The classification logic mirrors the hardened ACME diagnostics probe
|
|
(services/acme_diagnostics.py, R18b/R18c audits): unwrap IPv4-mapped IPv6, reject
|
|
loopback/link-local/private/multicast/reserved/unspecified, resolve DNS off the
|
|
event loop, and pin the aiohttp connector to IPv4 so the family the guard
|
|
classifies equals the family the connector dials (no dual-stack AAAA bypass).
|
|
|
|
Deployment note: this project uses ONLY public ACME CAs (e.g. Let's Encrypt), so
|
|
every non-public IP is rejected — there is no internal/private-IP CA to allow.
|
|
Residual: DNS rebinding between validate-time and fetch-time is not fully closed
|
|
(fetching by hostname keeps TLS cert validation working); the IPv4 pin +
|
|
https-only + admin-gating + internal-only exposure keep this residual low.
|
|
"""
|
|
import asyncio
|
|
import ipaddress
|
|
import socket
|
|
from typing import List
|
|
from urllib.parse import urlparse
|
|
|
|
import aiohttp
|
|
|
|
# Only https is legitimate for a public ACME directory URL.
|
|
_ALLOWED_SCHEMES = {"https"}
|
|
|
|
|
|
class SSRFValidationError(ValueError):
|
|
"""Raised when a URL fails SSRF validation (bad scheme or non-public host)."""
|
|
|
|
|
|
def is_public_ip(ip_str: str) -> bool:
|
|
"""Return True only for globally-routable IPv4/IPv6 addresses.
|
|
|
|
Unwraps IPv4-mapped IPv6 (``::ffff:127.0.0.1``) before classification so an
|
|
attacker-controlled AAAA record cannot smuggle loopback/metadata through the
|
|
IPv6 checks.
|
|
"""
|
|
try:
|
|
ip = ipaddress.ip_address(ip_str)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
ip = ip.ipv4_mapped
|
|
if ip.is_loopback or ip.is_link_local or ip.is_private:
|
|
return False
|
|
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
|
return False
|
|
return True
|
|
|
|
|
|
async def _resolve_ips(host: str, *, timeout: float = 5.0) -> List[str]:
|
|
"""Resolve `host` to IPv4 addresses without blocking the event loop."""
|
|
loop = asyncio.get_running_loop()
|
|
_, _, ips = await asyncio.wait_for(
|
|
loop.run_in_executor(None, socket.gethostbyname_ex, host),
|
|
timeout=timeout,
|
|
)
|
|
return ips or []
|
|
|
|
|
|
async def assert_public_url(url: str, *, timeout: float = 5.0) -> None:
|
|
"""Validate that `url` is safe to fetch server-side.
|
|
|
|
Requirements: https scheme, and a host that either is a public IP literal or
|
|
resolves entirely to public IPv4 addresses. Raises ``SSRFValidationError``
|
|
otherwise. Intended to be called immediately before the outbound request,
|
|
which MUST use ``safe_connector()`` and ``allow_redirects=False``.
|
|
"""
|
|
if not url or not isinstance(url, str):
|
|
raise SSRFValidationError("A URL is required")
|
|
parsed = urlparse(url.strip())
|
|
if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
|
|
raise SSRFValidationError(f"URL scheme must be https (got '{parsed.scheme or 'none'}')")
|
|
host = parsed.hostname
|
|
if not host:
|
|
raise SSRFValidationError("URL has no host")
|
|
|
|
# Literal IP host: classify directly, no DNS needed.
|
|
try:
|
|
ipaddress.ip_address(host)
|
|
if not is_public_ip(host):
|
|
raise SSRFValidationError(f"URL host {host} is not a public IP address")
|
|
return
|
|
except ValueError:
|
|
pass # hostname, not an IP literal -> resolve below
|
|
|
|
try:
|
|
ips = await _resolve_ips(host, timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
raise SSRFValidationError(f"DNS resolution timed out for {host}")
|
|
except Exception as e: # socket.gaierror etc.
|
|
raise SSRFValidationError(f"DNS resolution failed for {host}: {e}")
|
|
|
|
if not ips:
|
|
raise SSRFValidationError(f"{host} did not resolve to any address")
|
|
if not all(is_public_ip(ip) for ip in ips):
|
|
raise SSRFValidationError(
|
|
f"{host} resolves to a non-public IP {ips} — refusing to fetch (SSRF guard)"
|
|
)
|
|
|
|
|
|
def safe_connector() -> aiohttp.TCPConnector:
|
|
"""IPv4-pinned aiohttp connector.
|
|
|
|
Forces the connect family to match what :func:`assert_public_url` classified
|
|
(closes the dual-stack AAAA bypass). TLS verification stays ON (default), so
|
|
the request must target the validated hostname. Always combine with
|
|
``allow_redirects=False`` at the request call site.
|
|
"""
|
|
return aiohttp.TCPConnector(family=socket.AF_INET)
|