Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ def _get_env(var: str, fallback: str | None = None) -> str | None:

# PLAYMATCH
PLAYMATCH_API_ENABLED: Final[bool] = safe_str_to_bool(_get_env("PLAYMATCH_API_ENABLED"))
# Base URL of the Playmatch API, overridable to point at a self-hosted instance.
PLAYMATCH_API_URL: Final[str] = _get_env(
"PLAYMATCH_API_URL", "https://playmatch.retrorealm.dev/api/v2"
).rstrip("/")
Comment thread
gantoine marked this conversation as resolved.

# HASHEOUS
HASHEOUS_API_ENABLED: Final[bool] = safe_str_to_bool(_get_env("HASHEOUS_API_ENABLED"))
Expand Down
4 changes: 2 additions & 2 deletions backend/handler/metadata/playmatch_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import yarl
from fastapi import HTTPException, status

from config import PLAYMATCH_API_ENABLED
from config import PLAYMATCH_API_ENABLED, PLAYMATCH_API_URL
from handler.metadata.base_handler import MetadataHandler
from logger.logger import log
from models.rom import Rom, RomFile
Expand Down Expand Up @@ -104,7 +104,7 @@ class PlaymatchHandler(MetadataHandler):
"""

def __init__(self):
self.base_url = "https://playmatch.retrorealm.dev/api/v2"
self.base_url = PLAYMATCH_API_URL
self.identify_url = f"{self.base_url}/identify/ids"
self.healthcheck_url = f"{self.base_url}/health"
self.suggestion_url = f"{self.base_url}/suggestion/external/game"
Expand Down
90 changes: 90 additions & 0 deletions backend/tests/utils/test_ssrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from utils.ssrf import (
SSRFProtectedAsyncBackend,
SSRFProtectedSyncBackend,
_parse_origin,
is_forbidden_ip,
parse_ip_literal,
validate_url_for_http_request,
Expand Down Expand Up @@ -278,6 +279,95 @@ def test_literal_forbidden_ip_rejected(self):
inner.connect_tcp.assert_not_called()


class TestInternalOriginAllowlist:
"""Admin-configured origins (e.g. self-hosted Playmatch) bypass SSRF checks,
but only for the exact host:port that was trusted."""

def test_parse_origin_normalizes(self):
assert _parse_origin("http://playmatch:8000/api/v2") == ("playmatch", 8000)
assert _parse_origin("https://pm.example.com/api") == ("pm.example.com", 443)
assert _parse_origin("http://LAN-HOST/") == ("lan-host", 80)

def test_parse_origin_rejects_invalid(self):
assert _parse_origin("") is None # empty is skipped
assert _parse_origin("ftp://nope") is None # non-http scheme is skipped

async def test_async_allowlisted_ip_connects_without_validation(self):
"""A trusted private LAN IP:port connects directly, no rebinding check."""
calls: list[ConnectCall] = []
inner = _stub_async_inner(calls)
backend = SSRFProtectedAsyncBackend(
inner=inner, allowlist=frozenset({("192.168.1.50", 8000)})
)

await backend.connect_tcp("192.168.1.50", 8000)

assert calls[0][0][0] == "192.168.1.50"
assert calls[0][0][1] == 8000

async def test_async_allowlisted_hostname_connects_without_resolution(
self, monkeypatch
):
"""A trusted Docker service name connects via the inner backend directly."""
calls: list[ConnectCall] = []
inner = _stub_async_inner(calls)
backend = SSRFProtectedAsyncBackend(
inner=inner, allowlist=frozenset({("playmatch", 8000)})
)

def _boom(*args, **kwargs):
raise AssertionError("allowlisted host must not be resolved")

monkeypatch.setattr(asyncio.get_running_loop(), "getaddrinfo", _boom)

await backend.connect_tcp("playmatch", 8000)

assert calls[0][0][0] == "playmatch"

async def test_async_allowlist_is_port_scoped(self, monkeypatch):
"""Same host on a non-trusted port still gets the full SSRF check."""
inner = _stub_async_inner([])
backend = SSRFProtectedAsyncBackend(
inner=inner, allowlist=frozenset({("192.168.1.50", 8000)})
)

with pytest.raises(httpcore.ConnectError, match="forbidden IP"):
await backend.connect_tcp("192.168.1.50", 9999)
inner.connect_tcp.assert_not_called()

def test_sync_allowlisted_ip_connects_without_validation(self):
calls: list[ConnectCall] = []
inner = _stub_sync_inner(calls)
backend = SSRFProtectedSyncBackend(
inner=inner, allowlist=frozenset({("192.168.1.50", 8000)})
)

backend.connect_tcp("192.168.1.50", 8000)

assert calls[0][0][0] == "192.168.1.50"

def test_validator_allows_trusted_lan_ip(self):
"""The static gate lets a trusted literal LAN IP:port through."""
allow = frozenset({("192.168.1.50", 8000)})
# Would raise without the allowlist (private IP literal).
validate_url_for_http_request(
"http://192.168.1.50:8000/health", allowlist=allow
)
with pytest.raises(ValidationError):
validate_url_for_http_request(
"http://192.168.1.50:8000/health", allowlist=frozenset()
)

def test_validator_allows_trusted_localhost(self):
"""The static gate lets a trusted localhost:port through despite the deny list."""
allow = frozenset({("localhost", 8000)})
validate_url_for_http_request("http://localhost:8000/health", allowlist=allow)
with pytest.raises(ValidationError):
validate_url_for_http_request(
"http://localhost:8000/health", allowlist=frozenset()
)


class TestRequestEventHook:
"""Verify the syntactic URL validator is wired as a request event hook.

Expand Down
75 changes: 72 additions & 3 deletions backend/utils/ssrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from urllib.parse import urlparse

import httpcore
import pydash
from httpcore._backends.auto import AutoBackend
from httpcore._backends.base import (
SOCKET_OPTION,
Expand All @@ -41,6 +42,7 @@
)
from httpcore._backends.sync import SyncBackend

from config import PLAYMATCH_API_URL
from logger.logger import log
from utils.validation import ValidationError

Expand Down Expand Up @@ -96,6 +98,37 @@ def parse_ip_literal(
return ipaddress.IPv4Address(packed)


def _parse_origin(origin: str) -> tuple[str, int] | None:
"""Return the (lowercased host, port) of an http(s) origin, else None."""
parts = urlparse(origin)
if parts.scheme not in ("http", "https") or not parts.hostname:
return None

try:
port = parts.port
except ValueError:
return None

if port is None:
port = 443 if parts.scheme == "https" else 80

return parts.hostname.lower(), port


# Origins the admin explicitly configured are trusted to reach private addresses.
# Matched by exact host and port so the exception stays as narrow as possible.
INTERNAL_ORIGIN_ALLOWLIST: frozenset[tuple[str, int]] = frozenset(
pydash.compact([_parse_origin(PLAYMATCH_API_URL)])
)


def _is_allowlisted_origin(
host: str, port: int, allowlist: frozenset[tuple[str, int]]
) -> bool:
"""True if this exact host:port was explicitly trusted by the admin."""
return (host.lower(), port) in allowlist


def _pick_safe_address(addr_infos: typing.Iterable[typing.Any], host: str) -> str:
"""Validate every resolved address and return the literal IP to connect to.

Expand Down Expand Up @@ -142,8 +175,13 @@ def _check_literal(host: str) -> bool:
class SSRFProtectedAsyncBackend(AsyncNetworkBackend):
"""Async backend that validates resolved IPs before establishing TCP."""

def __init__(self, inner: AsyncNetworkBackend | None = None) -> None:
def __init__(
self,
inner: AsyncNetworkBackend | None = None,
allowlist: frozenset[tuple[str, int]] = INTERNAL_ORIGIN_ALLOWLIST,
) -> None:
self._inner = inner or AutoBackend()
self._allowlist = allowlist

# `timeout` parameter is required by AsyncNetworkBackend.connect_tcp;
# ruff/ASYNC109 advises against timeout parameters on async APIs *we*
Expand All @@ -165,6 +203,11 @@ async def connect_tcp(
only timed out the TCP connect inside the inner backend, leaving
`loop.getaddrinfo` unbounded.
"""
if _is_allowlisted_origin(host, port, self._allowlist):
return await self._inner.connect_tcp(
host, port, timeout, local_address, socket_options
)

if _check_literal(host):
return await self._inner.connect_tcp(
host, port, timeout, local_address, socket_options
Expand Down Expand Up @@ -204,8 +247,13 @@ async def sleep(self, seconds: float) -> None:
class SSRFProtectedSyncBackend(NetworkBackend):
"""Sync backend that validates resolved IPs before establishing TCP."""

def __init__(self, inner: NetworkBackend | None = None) -> None:
def __init__(
self,
inner: NetworkBackend | None = None,
allowlist: frozenset[tuple[str, int]] = INTERNAL_ORIGIN_ALLOWLIST,
) -> None:
self._inner = inner if inner is not None else SyncBackend()
self._allowlist = allowlist

def connect_tcp(
self,
Expand All @@ -215,6 +263,11 @@ def connect_tcp(
local_address: str | None = None,
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
) -> NetworkStream:
if _is_allowlisted_origin(host, port, self._allowlist):
return self._inner.connect_tcp(
host, port, timeout, local_address, socket_options
)

if _check_literal(host):
return self._inner.connect_tcp(
host, port, timeout, local_address, socket_options
Expand Down Expand Up @@ -268,7 +321,11 @@ def install_sync_ssrf_protection(client: typing.Any) -> None:
]


def validate_url_for_http_request(url: str, field_name: str = "URL") -> None:
def validate_url_for_http_request(
url: str,
field_name: str = "URL",
allowlist: frozenset[tuple[str, int]] = INTERNAL_ORIGIN_ALLOWLIST,
) -> None:
"""Syntactically validate a URL before passing it to an HTTP client.

Fast-fail check for cases that don't need DNS to detect:
Expand Down Expand Up @@ -322,6 +379,18 @@ def validate_url_for_http_request(url: str, field_name: str = "URL") -> None:
log.error(msg)
raise ValidationError(msg, field_name)

# Allow admin-configured internal origins (e.g. self-hosted Playmatch)
# through the static gate; connect-time validation still pins the IP.
if allowlist:
try:
effective_port = parsed.port or (443 if parsed.scheme == "https" else 80)
except ValueError:
effective_port = None
if effective_port is not None and _is_allowlisted_origin(
hostname, effective_port, allowlist
):
return

# Block reserved hostnames that are commonly used to refer to internal services.
if hostname.lower() in RESERVED_HOSTNAMES:
msg = f"Invalid {field_name}: localhost and reserved hostnames are not allowed"
Expand Down
1 change: 1 addition & 0 deletions env.template
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ STEAMGRIDDB_API_KEY= # SteamGridDB secret API key
RETROACHIEVEMENTS_API_KEY= # RetroAchievements secret API key
REFRESH_RETROACHIEVEMENTS_CACHE_DAYS=30 # RetroAchievements metadata cache refresh interval in days
PLAYMATCH_API_ENABLED=false # Enable PlayMatch API integration
# PLAYMATCH_API_URL=https://playmatch.retrorealm.dev/api/v2 # Point at a self-hosted PlayMatch instance
LAUNCHBOX_API_ENABLED=false # Enable LaunchBox API integration
HASHEOUS_API_ENABLED=false # Enable Hasheous API integration
FLASHPOINT_API_ENABLED=false # Enable Flashpoint API integration
Expand Down
Loading