Skip to content

Commit a3985e1

Browse files
committed
Add SSRF protection for connector and MCP URLs, and HMAC-hash API keys
Outbound requests to user-configured connector and MCP URLs now block private, loopback, and link-local targets by default; ALLOW_PRIVATE_CONNECTOR_URLS opts back in for trusted single-tenant deploys. API keys are stored as a keyed HMAC-SHA256 peppered with the server secret. Resolves the CodeQL SSRF and weak-hashing alerts.
1 parent 98eab75 commit a3985e1

11 files changed

Lines changed: 174 additions & 4 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ S3_SECRET_KEY=
4747
RUN_MAX_STEPS=25
4848
RUN_MAX_TOKENS=200000
4949
RUN_MAX_WALLCLOCK_SEC=600
50+
# Block outbound connector/MCP requests to private, loopback, and link-local
51+
# addresses (SSRF protection). Set true only on trusted single-tenant deploys
52+
# that must reach internal endpoints.
53+
ALLOW_PRIVATE_CONNECTOR_URLS=false
5054
# When true, agent runs execute inline instead of in the background (tests/dev).
5155
AGENT_RUN_INLINE=false
5256
# When true, document ingestion runs inline instead of via a Celery task (tests/dev).

apps/api/legate/agent/builtin_tools.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import httpx
1212

1313
from legate.agent.tooling import ToolContext, ToolRegistry, ToolResult, ToolSpec
14+
from legate.security.ssrf import SSRFError, validate_public_url
1415

1516
_MAX_BODY_CHARS = 8_000
1617

@@ -80,6 +81,10 @@ async def http_request(args: dict, ctx: ToolContext) -> ToolResult:
8081
return ToolResult(ok=False, error="only read-only GET requests are permitted")
8182
if not (url.startswith("http://") or url.startswith("https://")):
8283
return ToolResult(ok=False, error="url must be an absolute http(s) URL")
84+
try:
85+
validate_public_url(url)
86+
except SSRFError as exc:
87+
return ToolResult(ok=False, error=str(exc))
8388

8489
try:
8590
if ctx.http_client is not None:

apps/api/legate/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ def cors_origin_list(self) -> list[str]:
7272
run_max_steps: int = 25
7373
run_max_tokens: int = 200_000
7474
run_max_wallclock_sec: int = 600
75+
# Outbound requests to connector / MCP URLs block private, loopback, and
76+
# link-local targets to prevent SSRF. Trusted single-tenant deployments that
77+
# need internal endpoints can opt back in.
78+
allow_private_connector_urls: bool = False
7579

7680
# When true, POST /agents/{id}/run executes the agent loop inline before
7781
# responding instead of scheduling it in the background. Used by tests for

apps/api/legate/connectors/http.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from legate.agent.tooling import ToolContext, ToolResult, ToolSpec
1313
from legate.connectors.base import BaseConnector, ConnectionResult, ConnectorConfigError
1414
from legate.connectors.runtime import new_async_client
15+
from legate.security.ssrf import SSRFError, validate_public_url
1516

1617
_MAX_BODY_CHARS = 8_000
1718
_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"}
@@ -82,6 +83,10 @@ async def test_connection(self, config: dict, secrets: dict) -> ConnectionResult
8283
target = config.get("test_url") or config.get("base_url")
8384
if not target:
8485
return ConnectionResult(ok=True, message="no test_url/base_url configured to probe")
86+
try:
87+
validate_public_url(target)
88+
except SSRFError as exc:
89+
return ConnectionResult(ok=False, message=str(exc))
8590
try:
8691
async with new_async_client() as client:
8792
resp = await client.get(target, headers=self._auth_headers(config, secrets))
@@ -101,6 +106,10 @@ async def execute(
101106
url = self._resolve_url(config, str(args.get("url", "")))
102107
if not url:
103108
return ToolResult(ok=False, error="url is required")
109+
try:
110+
validate_public_url(url)
111+
except SSRFError as exc:
112+
return ToolResult(ok=False, error=str(exc))
104113

105114
headers = {**self._auth_headers(config, secrets), **(args.get("headers") or {})}
106115
try:

apps/api/legate/connectors/webhook.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from legate.agent.tooling import ToolContext, ToolResult, ToolSpec
1818
from legate.connectors.base import BaseConnector, ConnectionResult, ConnectorConfigError
1919
from legate.connectors.runtime import new_async_client
20+
from legate.security.ssrf import SSRFError, validate_public_url
2021

2122
_DEFAULT_SIGNATURE_HEADER = "X-Legate-Signature"
2223

@@ -82,6 +83,10 @@ async def execute(
8283
url = config.get("url")
8384
if not url:
8485
return ToolResult(ok=False, error="no url configured")
86+
try:
87+
validate_public_url(url)
88+
except SSRFError as exc:
89+
return ToolResult(ok=False, error=str(exc))
8590

8691
payload = args.get("payload")
8792
if payload is None:

apps/api/legate/mcp/client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import httpx
1717

1818
from legate.connectors.runtime import new_async_client
19+
from legate.security.ssrf import SSRFError, validate_public_url
1920

2021
_PROTOCOL_VERSION = "2025-06-18"
2122
_CLIENT_INFO = {"name": "legate-agent", "version": "0.1"}
@@ -100,8 +101,16 @@ async def _open_session(client: httpx.AsyncClient, url: str, token: str | None)
100101
return session_id
101102

102103

104+
def _guard(url: str) -> str:
105+
try:
106+
return validate_public_url(url)
107+
except SSRFError as exc:
108+
raise MCPError(str(exc)) from exc
109+
110+
103111
async def list_tools(url: str, token: str | None = None) -> list[dict]:
104112
"""Discover the tools a server offers. Returns raw MCP tool definitions."""
113+
url = _guard(url)
105114
async with new_async_client() as client:
106115
session = await _open_session(client, url, token)
107116
try:
@@ -119,6 +128,7 @@ async def list_tools(url: str, token: str | None = None) -> list[dict]:
119128

120129
async def call_tool(url: str, token: str | None, name: str, arguments: dict) -> dict:
121130
"""Invoke a tool on the server and return the raw MCP result."""
131+
url = _guard(url)
122132
async with new_async_client() as client:
123133
session = await _open_session(client, url, token)
124134
try:

apps/api/legate/security/api_keys.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
"""API key generation and hashing.
22
3-
A key looks like ``lg_<43 url-safe chars>``. Only its SHA-256 digest and a short
4-
non-secret prefix are stored; the plaintext is shown to the caller exactly once.
3+
A key looks like ``lg_<43 url-safe chars>`` (a 256-bit random token). Only a keyed
4+
HMAC-SHA256 digest and a short non-secret prefix are stored; the plaintext is
5+
shown to the caller exactly once. The HMAC is peppered with the server secret, so
6+
a database leak alone cannot be used to recover or verify keys, and it stays
7+
deterministic so lookups by digest still work.
58
"""
69

710
from __future__ import annotations
811

912
import hashlib
13+
import hmac
1014
import secrets
1115
from dataclasses import dataclass
1216

17+
from legate.config import get_settings
18+
1319
KEY_PREFIX = "lg_"
1420

1521

@@ -21,7 +27,8 @@ class GeneratedApiKey:
2127

2228

2329
def hash_api_key(plaintext: str) -> str:
24-
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
30+
pepper = get_settings().legate_secret_key.encode("utf-8")
31+
return hmac.new(pepper, plaintext.encode("utf-8"), hashlib.sha256).hexdigest()
2532

2633

2734
def generate_api_key() -> GeneratedApiKey:

apps/api/legate/security/ssrf.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""SSRF guard for outbound requests to user-configured URLs.
2+
3+
Connectors and MCP servers point Legate at URLs the user provides, which is the
4+
feature, but on a shared deployment a malicious URL could target internal
5+
services (cloud metadata at 169.254.169.254, localhost admin ports, private
6+
network hosts). :func:`validate_public_url` resolves the host and rejects any URL
7+
that lands on a private, loopback, link-local, or otherwise non-public address.
8+
9+
Trusted single-tenant deployments that legitimately need internal endpoints can
10+
set ``ALLOW_PRIVATE_CONNECTOR_URLS=true`` to bypass the check.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import ipaddress
16+
import socket
17+
from urllib.parse import urlparse
18+
19+
from legate.config import Settings, get_settings
20+
21+
_BLOCKED_ATTRS = (
22+
"is_private",
23+
"is_loopback",
24+
"is_link_local",
25+
"is_reserved",
26+
"is_multicast",
27+
"is_unspecified",
28+
)
29+
30+
31+
class SSRFError(ValueError):
32+
"""Raised when a URL is not allowed for outbound requests."""
33+
34+
35+
def _is_blocked(ip: str) -> bool:
36+
addr = ipaddress.ip_address(ip)
37+
return any(getattr(addr, attr) for attr in _BLOCKED_ATTRS)
38+
39+
40+
def _resolve(host: str) -> list[str]:
41+
try:
42+
infos = socket.getaddrinfo(host, None)
43+
except socket.gaierror as exc:
44+
raise SSRFError(f"could not resolve host: {host}") from exc
45+
return [str(info[4][0]) for info in infos]
46+
47+
48+
def validate_public_url(url: str, *, settings: Settings | None = None) -> str:
49+
"""Return the URL if it is safe to fetch, otherwise raise :class:`SSRFError`."""
50+
settings = settings or get_settings()
51+
parsed = urlparse(url)
52+
if parsed.scheme not in ("http", "https"):
53+
raise SSRFError("url must use http or https")
54+
host = parsed.hostname
55+
if not host:
56+
raise SSRFError("url has no host")
57+
58+
if settings.allow_private_connector_urls:
59+
return url
60+
61+
# A literal IP needs no DNS; a hostname is resolved and every address checked
62+
# so a name that maps to a private range cannot slip through.
63+
try:
64+
ipaddress.ip_address(host)
65+
candidates = [host]
66+
except ValueError:
67+
candidates = _resolve(host)
68+
69+
for ip in candidates:
70+
if _is_blocked(ip):
71+
raise SSRFError(f"url resolves to a blocked (non-public) address: {ip}")
72+
return url

apps/api/tests/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
1515
os.environ.setdefault("LEGATE_SECRET_KEY", "test-secret-key-do-not-use-in-prod")
1616
os.environ.setdefault("LEGATE_ENV", "test")
17+
# Tests exercise connectors against mock transports on placeholder hosts, so the
18+
# SSRF guard is disabled here; ssrf-specific tests pass an explicit settings.
19+
os.environ.setdefault("ALLOW_PRIVATE_CONNECTOR_URLS", "true")
1720
# Credential vault master key (base64 of 32 bytes).
1821
os.environ.setdefault("LEGATE_MASTER_KEY", base64.b64encode(b"0" * 32).decode())
1922
# Agent + workflow runs execute inline so they complete deterministically.

apps/api/tests/test_ssrf.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Tests for the SSRF guard used by connectors and the MCP client."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from legate.config import Settings
7+
from legate.security.ssrf import SSRFError, validate_public_url
8+
9+
# Guard active (blocks private targets).
10+
STRICT = Settings(allow_private_connector_urls=False)
11+
# Guard disabled (trusted single-tenant opt-in).
12+
RELAXED = Settings(allow_private_connector_urls=True)
13+
14+
15+
@pytest.mark.parametrize(
16+
"url",
17+
[
18+
"http://127.0.0.1/x", # loopback
19+
"https://10.0.0.5/admin", # private
20+
"http://192.168.1.10", # private
21+
"http://169.254.169.254/latest/meta-data", # cloud metadata (link-local)
22+
"http://[::1]/", # IPv6 loopback
23+
"http://0.0.0.0", # unspecified
24+
],
25+
)
26+
def test_blocks_internal_targets(url: str) -> None:
27+
with pytest.raises(SSRFError):
28+
validate_public_url(url, settings=STRICT)
29+
30+
31+
@pytest.mark.parametrize("url", ["https://8.8.8.8", "https://93.184.216.34/path"])
32+
def test_allows_public_ip(url: str) -> None:
33+
assert validate_public_url(url, settings=STRICT) == url
34+
35+
36+
@pytest.mark.parametrize("url", ["ftp://example.com", "file:///etc/passwd", "gopher://x"])
37+
def test_rejects_non_http_schemes(url: str) -> None:
38+
with pytest.raises(SSRFError):
39+
validate_public_url(url, settings=STRICT)
40+
41+
42+
def test_rejects_missing_host() -> None:
43+
with pytest.raises(SSRFError):
44+
validate_public_url("http://", settings=STRICT)
45+
46+
47+
def test_opt_in_bypasses_the_guard() -> None:
48+
# With the flag on, an internal URL is allowed (and no DNS is attempted).
49+
assert validate_public_url("http://127.0.0.1:9000/rpc", settings=RELAXED)
50+
assert validate_public_url("http://internal-host.local/rpc", settings=RELAXED)

0 commit comments

Comments
 (0)