Skip to content

feat(mcp): configurable transport host/Origin allowlist#397

Merged
gbrlcustodio merged 23 commits into
devfrom
feat/303-transport-host-allowlist
Jul 14, 2026
Merged

feat(mcp): configurable transport host/Origin allowlist#397
gbrlcustodio merged 23 commits into
devfrom
feat/303-transport-host-allowlist

Conversation

@gbrlcustodio

@gbrlcustodio gbrlcustodio commented Jul 12, 2026

Copy link
Copy Markdown
Member

Closes #303.

What

Makes the HTTP transport's DNS-rebinding host/Origin allowlist configurable. The public host is derived from resource_server_url, so a proxied deployment serves without a 421 while DNS-rebinding protection stays on, and the loopback-only default holds when nothing is configured.

Why

FastMCP auto-enables a loopback-only allowlist when built with a loopback host and no explicit transport_security. build_pipefy_mcp_server always constructs FastMCP(host=settings.mcp.host, ...) with the default 127.0.0.1, so behind a proxy that forwards the public Host the transport answers 421 Misdirected Request before any handler runs. The bind interface is irrelevant; the construction-time host arg is what trips it.

The change

  • McpSettings.allowed_hosts / allowed_origins (env PIPEFY_MCP_ALLOWED_HOSTS / PIPEFY_MCP_ALLOWED_ORIGINS as JSON), with a light normalization validator (strip, drop empties; localhost is a wanted entry, so no SSRF gate).
  • ResourceServer (moved into the auth module) parses resource_server_url once into the raw URL plus its wire-form host authorities. The runtime parses it a single time and feeds that one value to both consumers, the allowlist builder and the inbound resource-server auth, so they cannot disagree on the host.
  • core/transport_security.py:build_transport_security(mcp, resource): pure resolver. Derives loopback plus the resource host (bare and host:*) plus explicit entries; returns None only when there is no resource and allowed_origins is unset, so FastMCP's own default stands. Origins are derived http/https per host unless allowed_origins overrides; an explicit empty allowed_origins is honored verbatim as the strictest posture (reject any request that carries an Origin).
  • build_pipefy_mcp_server passes the resolved value into FastMCP(transport_security=...).

Placement: this is configuration resolution, not an MCP extension, so it mirrors build_resource_server_auth (settings to the SDK's AuthSettings) and lives in the composition tier (core/, target wiring/), keeping the mcp SDK out of settings.py. No enable/disable toggle: widen via the allowlist. No X-Forwarded-Host handling (the nginx ingress preserves Host).

Acceptance criteria

  • Behind a proxy with a public Host the server does not return 421: exercised by test_http_configured_allowlist_accepts_a_public_host.
  • The loopback-only default is preserved when the allowlist is unset: build_transport_security returns None, and test_http_loopback_default_rejects_a_public_host shows the default still 421s a public Host.

Testing

  • Full non-integration suite (packages/infra/tests packages/mcp/tests): 1630 passed, 9 skipped.
  • cd packages/mcp && uv run lint-imports: contract kept.
  • uv run ruff check packages/mcp && uv run ruff format --check packages/mcp: clean.

Migration note

Once resource_server_url is set, DNS-rebinding protection is on. This deliberately does not reproduce the wrapper's current "empty MCP_ALLOWED_HOSTS disables the check" escape; widening is done by allowlisting, not by turning protection off.

@gbrlcustodio gbrlcustodio self-assigned this Jul 13, 2026
@gbrlcustodio gbrlcustodio added refactor hosted-mcp Hosted, multi-user, on-behalf-of MCP server profile labels Jul 13, 2026
@gbrlcustodio gbrlcustodio added this to the Hosted server foundation milestone Jul 13, 2026
@gbrlcustodio
gbrlcustodio force-pushed the feat/379-auth-posture-bind-interlock branch from 7bf41c9 to 9c81845 Compare July 13, 2026 16:32
@gbrlcustodio
gbrlcustodio force-pushed the feat/303-transport-host-allowlist branch from 5ca6f7c to 42fbe88 Compare July 13, 2026 16:33
@gbrlcustodio
gbrlcustodio changed the base branch from feat/379-auth-posture-bind-interlock to dev July 13, 2026 19:39
FastMCP auto-enables a loopback-only DNS-rebinding allowlist when built with
a loopback host and no explicit transport_security. build_pipefy_mcp_server
always constructs FastMCP with settings.mcp.host (default 127.0.0.1), so
behind a proxy that forwards the public Host the transport answers 421
Misdirected Request before any handler runs.

Add allowed_hosts / allowed_origins to McpSettings (env PIPEFY_MCP_ALLOWED_*
as JSON) and a build_transport_security resolver that widens the allowlist:
loopback plus the resource_server_url host (the public origin the remote
profile already declares) plus any explicit entries. The builder passes the
result into FastMCP. When nothing is configured it returns None, leaving
FastMCP's loopback-only default in force, so the local subprocess case is
unchanged.

The resolver is configuration resolution, not an MCP extension: it produces
a static SDK config value, mirroring build_resource_server_auth (settings to
the SDK's AuthSettings). It lives in the composition tier (core/), keeping
the mcp SDK out of the settings boundary.
Add PIPEFY_MCP_ALLOWED_HOSTS / PIPEFY_MCP_ALLOWED_ORIGINS to the config
reference (env table and TOML sample) and turn the AGENTS.md "separate
follow-up work" note into a "Transport allowlist" section describing the
derive-from-resource_server_url default and the explicit override.
@gbrlcustodio
gbrlcustodio force-pushed the feat/303-transport-host-allowlist branch from 42fbe88 to f87f3d7 Compare July 13, 2026 19:42
@gbrlcustodio
gbrlcustodio marked this pull request as ready for review July 13, 2026 19:43
Replace the ad-hoc strip-and-truthiness filter in _stripped_nonempty with pipefy_infra.coerce.optional_str, the shared empty-to-None coercion, so the allowlist normalizer drops blank entries through the same helper the rest of the config boundary uses.
The ResourceServer value object (verbatim resource URL plus its parsed Host wire forms) modeled an OAuth RFC 9728 concept that auth/resource_server.py already owns, but lived in core/transport_security.py, which merely consumed it. Move the type and its from_url parser next to build_resource_server_auth so the resource-server vocabulary sits in one module; core/transport_security.py now imports it. The new core > auth import keeps the inward-only layering contract.
auth published ResourceServer but never used it: the builder read the raw resource_server_url string while only transport security exercised the type. Parse the RFC 9728 resource identity into a ResourceServer at the auth boundary and derive the token's stamped resource and the advertised AuthSettings metadata from that one carrier, so the concern that owns and publishes the type also exercises it.
The inbound-auth builder and the transport-allowlist builder each parsed resource_server_url into their own ResourceServer, so the one URL was parsed twice by two consumers that could in principle disagree on the host. Make McpRuntime.for_profile the single wiring point: it parses the URL into one ResourceServer and feeds that instance to both build_resource_server_auth and build_transport_security, holding the results (inbound_auth, transport_security) for the server to read. The builders stay standalone functions and now receive the parsed resource rather than a raw setting; the remote-without-resource fail-fast moves to the runtime's resource gate. server.py drops its direct build_transport_security call and reads runtime.transport_security.
adriannoes
adriannoes previously approved these changes Jul 13, 2026

@adriannoes adriannoes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Reviewed with checkout + tests (1622 passed, CI green on f87f3d7d). The allowlist design matches #303: derive from resource_server_url, widen via config, keep protection on, and leave FastMCP's loopback default when nothing is set. Ready to merge from my side.

What worked well

Pure build_transport_security in the composition tier (mcp SDK kept out of settings), dual host / host:* forms that match the SDK matcher, and both AC directions covered (default still 421s a public Host; configured allowlist clears the gate). The migration note correctly refuses the empty-disable escape.

Also noted

  • CHANGELOG: worth an Unreleased bullet for PIPEFY_MCP_ALLOWED_HOSTS / PIPEFY_MCP_ALLOWED_ORIGINS and the intentional protection-on-when-RS-URL flip (this window also covers the deferred note from #396).
  • Rollout: on a non-loopback bind, FastMCP previously left Host/Origin validation off; once resource_server_url is set this PR turns the gate on. Unauthenticated traffic still 401s before the Host check. Sweep authenticated callers that reach the server by a non-public Host (service DNS); PIPEFY_MCP_ALLOWED_HOSTS covers them if any exist.

Comment thread docs/config.md Outdated
In build_resource_server_auth the defaulted required_scopes sat before the required default_issuer_url, reading as if default_issuer_url were optional. Both are keyword-only and every caller passes by keyword, so reordering is behavior-neutral.
build_transport_security gated origins on `if explicit_origins:`, so an explicit allowed_origins=[] was falsy and silently replaced by the derived origins, despite the field advertising it as a strict override. Gate on `is not None` (and the early-return guard likewise) so [] is kept verbatim: it rejects any request that sends an Origin, while a request with no Origin still passes. Hosts stay additive (empty behaves like unset, since entries are extra on top of the derived set); the config.md wording is corrected to match.
for_profile parsed the resource and built the allowlist, then inlined both profile branches. Keep the single parse in for_profile and dispatch to _for_remote_profile and _for_local_profile, each carrying its own profile-specific rationale. Both receive the one parsed resource/allowlist, so the single-parse invariant holds.
build_resource_server_auth resolved the issuer itself (jwt_validation.resolve_issuer_url + a default_issuer_url fallback) and raised when it was unresolvable, mixing a cross-settings resolution and its fail-fast into a builder that should just wire. Move both to McpRuntime._for_remote_profile, where they sit beside the existing resource fail-fast, and pass a resolved issuer_url into the builder. The resolution rule stays owned by JwtValidationSettings.resolve_issuer_url (tested in pipefy_auth), so the builder's two resolution tests were redundant; the unresolvable-issuer fail-fast is now a runtime test.
The field holds the host[:port] values the resource presents in the Host header, which is the URL authority (RFC 3986 / RFC 9110). 'forms' was vague filler and read as a near-synonym of transport_security._host_forms (the allowlist match forms); 'host_authorities' names what it holds, matches the docstring's own 'Host authorities' wording, and clears the collision.
StartupIdentity, RequestScopedIdentity, and the AuthSource union resolve the outbound httpx.Auth a session acts as: StartupIdentity via pipefy_auth credential resolution, RequestScopedIdentity via auth's own require_request_bearer. They lived in core/runtime.py only because the runtime consumes them. Move them to auth/session_identity.py (the outbound counterpart to request_identity.py's inbound bearer) and publish them from auth's contract, so runtime.py is just the composition root: own the engine, wire resources per profile, open sessions. core imports the AuthSource contract inward from auth; the layering contract holds.
runtime.py reached into pipefy_mcp.auth.resource_server and pipefy_mcp.auth.session_identity submodules while the tests consumed the same symbols through the pipefy_mcp.auth facade. Import through the facade so the composition root integrates with the auth concern via its published contract, matching the tests and collapsing two import blocks into one.
The list counterpart to optional_str: strip each entry and drop blanks. Gives the strip-and-drop-empty pattern used across the CLI and MCP settings one shared home beside its scalar sibling.
An all-blank allowed_hosts/allowed_origins (e.g. an unfilled template value) now normalizes to None rather than []. An empty allowed_origins is the strict reject-all-Origin posture, so a blank value must not silently select it; an explicit [] is still preserved. Folds the two normalizers into one field validator.
Dedupe host forms with dict.fromkeys and drop the _host_forms helper; derive origins directly, since the deduped hosts make a duplicate impossible and the membership guard was unreachable.
The per-profile builders return the identity and inbound-auth pair; for_profile makes the single constructor call, so the transport allowlist is no longer threaded through each builder only to forward it unchanged.
The existing remote tests pin an explicit PIPEFY_JWT_ISSUER_URL, masking the override-absent path where the inbound issuer defaults to the login issuer this process authenticates against.
build_resource_server_auth stopped resolving the inbound issuer earlier in this stack, but the module docstring, package AGENTS.md, and the builder-test docstring still credited it with that job.
…g it

A blank or whitespace-only entry in allowed_hosts/allowed_origins now fails at the settings boundary rather than being trimmed away. A silent drop hides the operator's typo, and for allowed_origins it could collapse the list to the strict reject-all-Origin posture. Real entries are still trimmed and an explicitly empty list is still accepted.
The transport allowlist normalizer now rejects blank entries rather than dropping them, so nonempty_strs has no remaining consumer. The CLI split sites that duplicate the pattern can reintroduce it when they actually adopt it.
_resource_server was a one-call helper whose docstring restated for_profile's own parsed-once rationale; fold it into for_profile and drop the now-unused ResourceServerSettings import.
adriannoes
adriannoes previously approved these changes Jul 14, 2026

@adriannoes adriannoes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

  • Double-checked the commits after the previous APPROVE (f87f3d7d0c5d09b3).
  • Checkout + tests: 1634 passed, CI green. The #303 allowlist behavior still holds, and the empty-origins fix from the prior thread is in place (allowed_origins=[] honored via is not None, docs and unit tests updated). Ready to merge from my side.

What worked well

Single ResourceServer parse in McpRuntime.for_profile feeding both the allowlist and inbound auth, blank allowlist entries failing closed at settings, IPv6 authorities bracketed for well-formed URLs, and the composition refactors keeping the mcp SDK out of the settings boundary.

Also noted

  • CHANGELOG: still worth an Unreleased bullet for PIPEFY_MCP_ALLOWED_HOSTS / PIPEFY_MCP_ALLOWED_ORIGINS and the intentional protection-on-when-RS-URL flip (covers the deferred note from #396 as well).
  • Rollout: on a non-loopback bind, FastMCP previously left Host/Origin validation off; once resource_server_url is set this PR turns the gate on. Sweep authenticated callers that reach the server by a non-public Host (service DNS); PIPEFY_MCP_ALLOWED_HOSTS covers them if any exist.
  • Unbracketed IPv6: https://2001:db8::1/mcp can still pass ResourceServerSettings (urlparse sees host 2001) and then raise inside ResourceServer.from_url when reading .port. Bracketed forms work. Not a #303 AC blocker for DNS hostnames; worth a clear config error or settings-boundary reject if you touch that path again.

mocha06
mocha06 previously approved these changes Jul 14, 2026
Add Unreleased entries for the configurable DNS-rebinding Host/Origin
allowlist (PIPEFY_MCP_ALLOWED_HOSTS / PIPEFY_MCP_ALLOWED_ORIGINS and the
protection-on-when-resource-server-URL-set flip, #303) and the
auth-posture bind-safety interlock that #396 left undocumented (#379).
@gbrlcustodio
gbrlcustodio dismissed stale reviews from mocha06 and adriannoes via b149c4f July 14, 2026 19:50
@gbrlcustodio
gbrlcustodio merged commit 2a9916a into dev Jul 14, 2026
2 checks passed
@gbrlcustodio
gbrlcustodio deleted the feat/303-transport-host-allowlist branch July 14, 2026 19:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hosted-mcp Hosted, multi-user, on-behalf-of MCP server profile refactor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants