Skip to content

Commit 7d5651c

Browse files
authored
Merge pull request #50 from MaazAhmed47/codex/outbound-destination-guard-phase1
fix: harden outbound destination validation
2 parents bec2e8f + 85ad65c commit 7d5651c

25 files changed

Lines changed: 1349 additions & 121 deletions

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@
5656
# ENABLE_API_DOCS=false
5757

5858
# SSRF-oriented outbound URL checks for MCP discovery/calls and SIEM tests.
59-
# Defaults on in production. Keep private outbound disabled for shared hosted demos.
59+
# Defaults on in production and whenever a supported hosted marker is present.
60+
# Keep private outbound disabled for shared hosted demos.
6061
# INTERLOCK_PROTECT_OUTBOUND_URLS=true
6162
# INTERLOCK_ALLOW_PRIVATE_OUTBOUND=false
6263

README.md

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,7 +1165,10 @@ Production/hosted deployments should set:
11651165
- `INTERLOCK_ENV=production`
11661166
- explicit `ALLOWED_ORIGINS` for the dashboard origin; `*` is rejected in production
11671167
- `ENABLE_API_DOCS=false` unless the API docs are intentionally gated elsewhere
1168-
- default outbound URL protection enabled; only set `INTERLOCK_ALLOW_PRIVATE_OUTBOUND=true` for controlled local/private deployments
1168+
- outbound URL protection is mandatory in production; private destinations are rejected
1169+
- the bundled offline Compose proof uses a narrow `mcp-mock` allowance and does not enable a general private-destination override
1170+
- enforce a production egress proxy or firewall because application-level hostname checks are not a complete DNS-rebinding defense
1171+
- guarded HTTP clients ignore ambient proxy variables; a production proxy must be an explicit future design or a transparent enforced network boundary
11691172

11701173
Secrets hygiene:
11711174

@@ -1202,17 +1205,35 @@ Common variables:
12021205
| `DATABASE_URL` | Optional Postgres connection string for hosted/production deployments. |
12031206
| `REDIS_URL` | Optional Redis connection string for shared rate limits across workers/pods. |
12041207
| `FIREWALL_DB_PATH` | Local SQLite path; defaults to `data/firewall.db`. |
1205-
| `INTERLOCK_ENV` | Set to `production` for hosted deployments; local/dev keeps permissive defaults. |
1208+
| `INTERLOCK_ENV` | Set to `production` for hosted deployments; local/dev is permissive only when no supported hosted-platform marker is present. |
12061209
| `ALLOWED_ORIGINS` | Required in production; comma-separated dashboard origins for CORS. |
12071210
| `ENABLE_API_DOCS` | Defaults to off in production and on in local/dev. |
1208-
| `INTERLOCK_PROTECT_OUTBOUND_URLS` | Enables SSRF-oriented outbound URL checks; defaults on in production. |
1209-
| `INTERLOCK_ALLOW_PRIVATE_OUTBOUND` | Override for controlled private/local outbound URLs; avoid on shared hosted deployments. |
1211+
| `INTERLOCK_PROTECT_OUTBOUND_URLS` | Enables outbound URL checks in local/dev; protection is mandatory in production and hosted-platform environments. |
1212+
| `INTERLOCK_ALLOW_PRIVATE_OUTBOUND` | Legacy local-only compatibility setting; it cannot disable the shared guard or permit private production egress. |
1213+
| `INTERLOCK_OFFLINE_DEMO` | Enables the non-production Compose proof profile, including only the exact `mcp-mock` outbound allowance. |
12101214
| `MCP_UPSTREAM_AUTH_ALLOWED_ENV_VARS` | Comma-separated allowlist of environment-variable names that registered MCP servers may use for upstream auth; default deny. |
12111215
| `SHADOW_SCAN_ENABLED` | Opt-in background shadow MCP probing. |
12121216
| `SHADOW_SCAN_INTERVAL` | Shadow scan interval in seconds. |
12131217

12141218
---
12151219

1220+
### Outbound destination limitation
1221+
1222+
Interlock rejects non-global literal addresses and any hostname whose checked
1223+
A/AAAA answer includes a non-global address when outbound protection is active.
1224+
The check fails closed on resolution errors and covers configurable MCP, probe,
1225+
readback, webhook, SIEM, shadow-scan, and JWKS destinations.
1226+
1227+
hostname resolution rejection is a partial mitigation; DNS rebinding requires connection pinning or an enforced egress proxy/firewall.
1228+
1229+
The current HTTP transports resolve hostnames again when connecting. Production
1230+
deployments must therefore enforce destination policy at the network boundary;
1231+
see [Outbound destination security](docs/outbound-destination-security.md).
1232+
Guarded clients use `trust_env=False`, so ambient `HTTP_PROXY`, `HTTPS_PROXY`,
1233+
`ALL_PROXY`, and `NO_PROXY` settings are unsupported for these paths.
1234+
1235+
---
1236+
12161237
## Current State
12171238

12181239
Interlock is pre-release and intended for self-hosted or isolated non-production evaluation.

config.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,15 +256,19 @@ def cors_allowed_origins() -> list[str]:
256256

257257
def protect_outbound_urls() -> bool:
258258
"""Enable SSRF-oriented outbound URL validation."""
259+
if is_production() or is_hosted():
260+
return True
259261
raw = os.getenv("INTERLOCK_PROTECT_OUTBOUND_URLS")
260262
if raw is not None:
261263
return _truthy(raw)
262-
return is_production()
264+
return False
263265

264266

265267
def allow_private_outbound_urls() -> bool:
266-
"""Emergency/local override for private outbound URLs."""
267-
return _truthy(os.getenv("INTERLOCK_ALLOW_PRIVATE_OUTBOUND"))
268+
"""Legacy local-only switch; never permits private production egress."""
269+
return not is_production() and _truthy(
270+
os.getenv("INTERLOCK_ALLOW_PRIVATE_OUTBOUND")
271+
)
268272

269273

270274
def offline_demo_enabled() -> bool:

core/admin.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
from typing import Optional, List, Dict, Any, Set
1616

1717
import jwt
18+
import httpx
1819

1920
from fastapi import APIRouter, Header, HTTPException
2021
from pydantic import BaseModel, Field
2122

2223
from core import db
24+
from core.url_security import OutboundUrlRejected, ensure_safe_outbound_url
2325

2426
logger = logging.getLogger("interlock.admin")
2527
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -46,6 +48,38 @@
4648
_OIDC_LAST_CONFIG_ERROR_AT = 0.0
4749

4850

51+
class _GuardedPyJWKClient(jwt.PyJWKClient):
52+
"""PyJWT key selection with Interlock-controlled HTTP behavior."""
53+
54+
def fetch_data(self):
55+
jwk_set = None
56+
try:
57+
canonical_url = ensure_safe_outbound_url(self.uri, context="OIDC JWKS")
58+
with httpx.Client(
59+
timeout=self.timeout,
60+
follow_redirects=False,
61+
trust_env=False,
62+
) as client:
63+
response = client.get(canonical_url, headers=self.headers)
64+
if response.is_redirect:
65+
raise jwt.exceptions.PyJWKClientConnectionError(
66+
"OIDC JWKS redirect was rejected"
67+
)
68+
response.raise_for_status()
69+
jwk_set = response.json()
70+
except jwt.exceptions.PyJWKClientConnectionError:
71+
raise
72+
except (OutboundUrlRejected, httpx.HTTPError, ValueError) as exc:
73+
raise jwt.exceptions.PyJWKClientConnectionError(
74+
"OIDC JWKS fetch failed"
75+
) from exc
76+
else:
77+
return jwk_set
78+
finally:
79+
if self.jwk_set_cache is not None:
80+
self.jwk_set_cache.put(jwk_set)
81+
82+
4983
@dataclass(frozen=True)
5084
class AdminContext:
5185
auth_type: str
@@ -129,9 +163,10 @@ def _enforce_oidc_principal_allowlist(claims: Dict[str, Any]) -> None:
129163

130164
def _get_oidc_signing_key(token: str):
131165
global _OIDC_JWKS_CLIENT, _OIDC_JWKS_CLIENT_URL
132-
if _OIDC_JWKS_CLIENT is None or _OIDC_JWKS_CLIENT_URL != OIDC_JWKS_URL:
133-
_OIDC_JWKS_CLIENT = jwt.PyJWKClient(OIDC_JWKS_URL)
134-
_OIDC_JWKS_CLIENT_URL = OIDC_JWKS_URL
166+
canonical_url = ensure_safe_outbound_url(OIDC_JWKS_URL, context="OIDC JWKS")
167+
if _OIDC_JWKS_CLIENT is None or _OIDC_JWKS_CLIENT_URL != canonical_url:
168+
_OIDC_JWKS_CLIENT = _GuardedPyJWKClient(canonical_url)
169+
_OIDC_JWKS_CLIENT_URL = canonical_url
135170
return _OIDC_JWKS_CLIENT.get_signing_key_from_jwt(token).key
136171

137172

core/ci_boundary_review.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
from core.mcp_drift import ACTION_BY_SEVERITY, SEVERITY_ORDER
6767
from core.mcp_drift import classify_server_drift, classify_tool_drift
6868
from core.mcp_gateway import fetch_candidate_tool_surface
69-
from core.url_security import OutboundUrlRejected, ensure_safe_outbound_url
69+
from core.url_security import OutboundUrlRejected, ensure_safe_outbound_url_async
7070

7171
logger = logging.getLogger("interlock.ci_boundary_review")
7272

@@ -340,14 +340,16 @@ async def _observe(
340340
"mutated_state": False,
341341
}
342342
try:
343-
ensure_safe_outbound_url(server.get("url") or "", context="MCP discovery")
343+
server_url = await ensure_safe_outbound_url_async(
344+
server.get("url") or "", context="MCP discovery"
345+
)
344346
except OutboundUrlRejected:
345347
observation["error_class"] = "registry_url_rejected"
346348
return observation, None, []
347349

348350
try:
349351
result = await fetch_candidate_tool_surface(
350-
server["url"],
352+
server_url,
351353
timeout=boundary_review_timeout_seconds(),
352354
server_id=server_id,
353355
max_response_bytes=caps["max_response_bytes"],

core/effect_readback.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from core import db
2323
from core import drift_evidence
2424
from core.effect_drift import build_effect_profile
25-
from core.url_security import OutboundUrlRejected, ensure_safe_outbound_url
25+
from core.url_security import OutboundUrlRejected, ensure_safe_outbound_url_async
2626

2727
SCHEMA_ID = "interlock.readback-effect-drift-record"
2828
SCHEMA_VERSION = "1"
@@ -400,7 +400,7 @@ async def _call_upstream_tool(
400400
)
401401

402402
try:
403-
server_url = ensure_safe_outbound_url(
403+
server_url = await ensure_safe_outbound_url_async(
404404
server["url"], context="MCP readback probe"
405405
)
406406
headers = _resolve_upstream_auth_headers(server)
@@ -410,7 +410,9 @@ async def _call_upstream_tool(
410410
"method": "tools/call",
411411
"params": {"name": tool_name, "arguments": arguments or {}},
412412
}
413-
async with httpx.AsyncClient(timeout=30.0) as client:
413+
async with httpx.AsyncClient(
414+
timeout=30.0, follow_redirects=False, trust_env=False
415+
) as client:
414416
resp = await client.post(server_url, **_mcp_post_kwargs(payload, headers))
415417
status = getattr(resp, "status_code", None)
416418
body: Optional[Dict[str, Any]] = None

core/effective_permission.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from core import db
1818
from core import drift_evidence
19-
from core.url_security import OutboundUrlRejected, ensure_safe_outbound_url
19+
from core.url_security import OutboundUrlRejected, ensure_safe_outbound_url_async
2020

2121
EXPECTED_OUTCOMES = {"denied", "allowed"}
2222
OBSERVED_OUTCOMES = {
@@ -390,7 +390,9 @@ async def _call_upstream_for_observation(
390390
)
391391

392392
try:
393-
server_url = ensure_safe_outbound_url(server["url"], context="MCP probe")
393+
server_url = await ensure_safe_outbound_url_async(
394+
server["url"], context="MCP probe"
395+
)
394396
headers = _resolve_upstream_auth_headers(server)
395397
payload = {
396398
"jsonrpc": "2.0",
@@ -401,7 +403,9 @@ async def _call_upstream_for_observation(
401403
"arguments": probe.get("arguments") or {},
402404
},
403405
}
404-
async with httpx.AsyncClient(timeout=30.0) as client:
406+
async with httpx.AsyncClient(
407+
timeout=30.0, follow_redirects=False, trust_env=False
408+
) as client:
405409
resp = await client.post(server_url, **_mcp_post_kwargs(payload, headers))
406410
body: Optional[Dict[str, Any]] = {}
407411
try:

core/ema_auth.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from jwt.algorithms import RSAAlgorithm
2121

2222
from core.ema_config import EMASettings, HMACKeyRing
23+
from core.url_security import ensure_safe_outbound_url_async
2324

2425
_BASE64URL_RE = re.compile(r"^[A-Za-z0-9_-]*$")
2526
_KID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
@@ -196,13 +197,17 @@ async def _refresh(self) -> None:
196197
pool=self.settings.jwks_connect_timeout_seconds,
197198
)
198199
try:
200+
jwks_uri = await ensure_safe_outbound_url_async(
201+
self.settings.jwks_uri, context="EMA JWKS"
202+
)
199203
async with httpx.AsyncClient(
200204
transport=self._transport,
201205
timeout=timeout,
202206
follow_redirects=False,
207+
trust_env=False,
203208
) as client:
204209
body = await asyncio.wait_for(
205-
self._read_jwks_response(client),
210+
self._read_jwks_response(client, jwks_uri),
206211
timeout=self.settings.jwks_total_timeout_seconds,
207212
)
208213
document = json.loads(body)
@@ -223,10 +228,12 @@ async def _refresh(self) -> None:
223228
if key_id in keys:
224229
self._negative.pop(key_id, None)
225230

226-
async def _read_jwks_response(self, client: httpx.AsyncClient) -> bytes:
231+
async def _read_jwks_response(
232+
self, client: httpx.AsyncClient, jwks_uri: str
233+
) -> bytes:
227234
async with client.stream(
228235
"GET",
229-
self.settings.jwks_uri,
236+
jwks_uri,
230237
headers={"Accept": "application/json"},
231238
) as response:
232239
if response.is_redirect or response.status_code != 200:

core/mcp_gateway.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Optional, Dict, Any
1010
from models.schemas import ScanResult, ThreatLevel
1111
from core.metadata_policy import evaluate_metadata_policy
12-
from core.url_security import OutboundUrlRejected, ensure_safe_outbound_url
12+
from core.url_security import OutboundUrlRejected, ensure_safe_outbound_url_async
1313
from core.tool_inspector import inspect_tool_call
1414
from core.tool_metadata import normalize_tool_metadata
1515
from core import db
@@ -768,7 +768,9 @@ async def _fetch_tool_list_payload(
768768
"""
769769
profile = MCP_UPSTREAM_LEGACY_PROFILE
770770
try:
771-
server_url = ensure_safe_outbound_url(server_url, context="MCP discovery")
771+
server_url = await ensure_safe_outbound_url_async(
772+
server_url, context="MCP discovery"
773+
)
772774
registered = (
773775
db.lookup_mcp_server(server_id)
774776
if server_id
@@ -780,7 +782,9 @@ async def _fetch_tool_list_payload(
780782
auth_headers = _resolve_upstream_auth_headers(registered)
781783
profile = _upstream_protocol_profile(registered)
782784

783-
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
785+
async with httpx.AsyncClient(
786+
timeout=timeout, follow_redirects=False, trust_env=False
787+
) as client:
784788

785789
async def send(method: str) -> Dict[str, Any]:
786790
request_id = uuid.uuid4().hex
@@ -1705,9 +1709,13 @@ async def proxy_mcp_tool_call(
17051709

17061710
# 5. Forward to actual MCP server
17071711
try:
1708-
server_url = ensure_safe_outbound_url(server["url"], context="MCP server")
1712+
server_url = await ensure_safe_outbound_url_async(
1713+
server["url"], context="MCP server"
1714+
)
17091715
auth_headers = _resolve_upstream_auth_headers(server)
1710-
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as client:
1716+
async with httpx.AsyncClient(
1717+
timeout=30.0, follow_redirects=False, trust_env=False
1718+
) as client:
17111719
request_id = uuid.uuid4().hex
17121720
payload, protocol_headers = _upstream_request(
17131721
profile,

core/router.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,9 @@ async def forward_to_provider(
235235

236236
# Make request
237237
try:
238-
async with httpx.AsyncClient(timeout=120.0) as client:
238+
async with httpx.AsyncClient(
239+
timeout=120.0, follow_redirects=False, trust_env=False
240+
) as client:
239241
resp = await client.post(url, json=body, headers=headers)
240242
data = resp.json()
241243

0 commit comments

Comments
 (0)