Skip to content

feat: protectedResourceMetadata.resource from the request instead of requiring it to be configured - #2691

Open
aish1331 wants to merge 40 commits into
theagentrouter:mainfrom
aish1331:derive-resource-url
Open

aish1331 wants to merge 40 commits into
theagentrouter:mainfrom
aish1331:derive-resource-url

Conversation

@aish1331

@aish1331 aish1331 commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Description

Problem

The OAuth Protected Resource Metadata document (RFC 9728) was served as a static
HTTPRouteFilter direct response, so the resource identifier it advertises had to be known
when the HTTPRoute was generated. That forced protectedResourceMetadata.resource to be a
required field, with operators hand-writing their own external URL into the MCPRoute and
keeping it in sync by hand. It also made the field impossible to get right in ordinary cases:
the control plane cannot see the scheme, authority or port a client will actually use, so one
configured value is wrong as soon as the route is reachable on a second hostname, sits behind
a non-standard port, or is served over plain HTTP. The Keycloak example had to hardcode
resource: "http://127.0.0.1:1975/mcp" for exactly this reason.

Change

protectedResourceMetadata.resource is optional on v1alpha1 and v1beta1 (CRD and API docs
regenerated). When it is omitted, the identifier is derived per request, so one MCPRoute stays
correct on every hostname and port it is reachable on. An explicit value still wins, for a
deployment whose external URL is not recoverable from the forwarded headers.

Three places advertise the identifier:

Surface Produced by When resource is omitted
Metadata document MCP proxy scheme + Host + MCP path (URL.EscapedPath, well-known prefix removed)
403 insufficient_scope challenge MCP proxy that same identifier, via resourceMetadataURL
401 invalid_token challenge Envoy %REQ(X-FORWARDED-PROTO?:SCHEME)%://%REQ(:AUTHORITY)% plus the well-known path and the route's serving path, in the BackendTrafficPolicy response override

The proxy's scheme is the first hop of X-Forwarded-Proto. If that header is absent, it falls
back to https when the connection into the proxy is TLS and to http otherwise. The 401 uses
?:SCHEME because a missing X-Forwarded-Proto expands to -, which would advertise
-://host/.... A pinned resource skips derivation: the document emits the configured string
unchanged, trailing slash included, and the 401 is a static URL from
internalapi.OAuthProtectedResourceMetadataURL. The 403 uses that same helper, so the
controller and the proxy advertise one metadata-URL shape.

The 401 is the one value the proxy cannot compute. Envoy's JWT filter rejects the request
before it is proxied, so the ResponseOverride header carries the format string above, which
Envoy Gateway passes through to the local response policy's response_headers_to_add. The path
in that string is the route's serving path (spec.path, default /mcp), not the request path.

The well-known rule on the main HTTPRoute forwards to the MCP proxy with x-ai-eg-mcp-route
set, the same way MCP traffic does. The proxy looks the route up from that header and writes
the document.

Also in this PR

  • Cluster rewrite generalized. Two rules on the main HTTPRoute now forward to the proxy, so
    modifyMCPGatewayGeneratedCluster no longer matches on the /rule/0 suffix. The new
    clusterTargetsMCPProxyBackend parses the cluster name and matches the name segment only.
    The endpoint cannot be inspected instead: a Backend with static IPs becomes an EDS cluster
    with no inline load assignment. The authn-filter stripping in maybeUpdateMCPRoutes stays
    keyed on rule/0 specifically — widening it would re-apply JWT auth to the metadata endpoint
    and break unauthenticated discovery.
  • filterapi. MCPRouteAuthorization.ResourceMetadataURL is replaced by a route-level
    ProtectedResourceMetadata, filled by mcpRouteOAuth whenever securityPolicy.oauth is set.
    The document is served independently of whether authorization rules exist.
  • Response fidelity. A route that pins resource gets a byte-identical document, trailing
    slash included, and does not gain Cache-Control. The challenge URL still drops a trailing
    slash, as buildResourceMetadataURL did; that helper is now
    internalapi.OAuthProtectedResourceMetadataURL, shared by the controller and the proxy.
    CORS headers and the old direct response's "answer any method" behaviour are preserved.
    When resource is omitted, Cache-Control: no-store is set so a cache keyed only on the
    path cannot replay one host's document to another. The path comes from r.URL.EscapedPath(),
    so a percent-encoded segment is advertised as the client sent it.
  • Hostnames. The proxy does not allow-list Host. The generated HTTPRoute copies
    spec.hostnames, and the well-known rule is on that route, so Envoy rejects a Host that
    does not match before the proxy can reflect it. With no hostnames, every authority the
    listener accepts is reflected. An e2e covers this on mcp-route-oauth-hostnames:
    mcp.example.com is served, and a foreign Host (including the port-forward address) is not.
  • Docs and examples. New sections on the resource identifier and on audience validation.
    audiences is whatever the authorization server puts in aud. It has to agree with a
    derived identifier only when that server honours RFC 8707 and the client sends the advertised
    identifier as the resource parameter. The API godoc says the same. The Keycloak example
    keeps resource pinned on mcp-route and adds mcp-route-derived-resource at /mcp-derived
    for the derived case.

Behavior change

The OAuth Protected Resource Metadata endpoint (/.well-known/oauth-protected-resource/...) is
now served by the MCP proxy in the extproc sidecar, instead of an Envoy direct response. Its
availability depends on the sidecar being up and having loaded the route named by
x-ai-eg-mcp-route. While that route is not loaded yet (for example during an extproc
restart), the endpoint returns 503 so clients retry. It returns 404 only for a loaded route
that does not configure OAuth.

Related Issues/PRs (if applicable)

Fixes #2690
Related PR: #2676

Special notes for reviewers (if applicable)

Upgrade path: a cluster reconciled by an older controller has an HTTPRouteFilter serving
the metadata as a direct response, and the route rule no longer references it.
ensureOAuthResources deletes that superseded filter rather than leaving it dangling.
cleanupOAuthResources deletes it too, so a route that drops OAuth before the upgraded
controller first reconciles it does not keep the stale filter. There is a test for the delete,
and it costs one cached Get per reconcile once nothing is left to remove. A TODO marks the
helper for removal after v2.0. Call out in the release notes that a user who upgrades from
<=1.2 straight to a version after that removal must delete the stale HTTPRouteFilter by hand.

Please scrutinise the trust framing. Deriving the identifier from Host and
X-Forwarded-Proto means the advertised value depends on request headers. Envoy overwrites
X-Forwarded-Proto from the downstream connection, but it does not sanitize Host, so on a
listener with no hostname the advertised identifier reflects whatever authority the client
sent; even with a hostname, Envoy ignores the port when matching and forwards Host intact.
The proxy does not apply its own allow-list; it relies on that HTTPRoute match. The docs say
this explicitly and point operators at listener hostname / route hostnames or at pinning
resource. Both interpolation sites rest on Envoy rejecting an authority that could break out
of the surrounding syntax, so an e2e test sends a raw Host: evil"injected and asserts the
gateway rejects it rather than reflecting it into the 401 challenge or the JSON document. Worth
confirming this is the framing the project wants to commit to.

The 401 WWW-Authenticate header is the only part of this change that cannot be verified by a
unit test.
It relies on Envoy expanding %REQ(X-FORWARDED-PROTO?:SCHEME)% and
%REQ(:AUTHORITY)% in a header value that Envoy Gateway funnels into the local response
policy's response_headers_to_add. A unit test pins the exact string the controller emits, but
only e2e proves the expansion happens, so please give that job's result particular weight. If
the expansion does not work, the header contains the literal format string rather than a URL,
which fails loudly rather than silently.

externalPath intentionally reads r.URL.EscapedPath() rather than the x-ai-eg-original-path /
x-envoy-original-path headers this codebase normally uses for "the path the client sent."
Those headers are only trustworthy outbound, where we set them; nothing strips them off
inbound requests, so honoring them would let a client pick which document the proxy serves and
what identifier that document advertises. They would also be redundant: the generated MCP route
rules carry no URLRewrite filter, so the routed path is already the client's path.
TestExternalPath asserts client-supplied values are ignored and that a percent-encoded
segment is preserved.

tests/e2e/testdata/mcp_route_oauth.yaml keeps resource pinned on mcp-route-oauth and adds
mcp-route-oauth-derived, which omits it, plus mcp-route-oauth-hostnames, which omits it and
sets hostnames. The derived route is what the port-forward exercises: the gateway is reached
on a port chosen at run time, which no statically configured identifier could have matched.
tests/e2e/testdata/mcp_route_authorization.yaml likewise keeps a pinned route and adds
mcp-route-authorization-derived, so the 403 insufficient_scope challenge — the one
resource_metadata URL the proxy builds rather than Envoy — is covered for both a pinned and a
derived identifier.

Testing

The following MCPRoutes were tested with Cursor MCP Client. We could connect with the MCP Server completing OAuth handshake. List Tools and Tool calls to this MCP Server verified.

MCPRoute without protected resource configured
apiVersion: aigateway.envoyproxy.io/v1beta1
kind: MCPRoute
metadata:
  name: mcp-oauth-test-no-prm
spec:
  path: /mcp/oauth-test-no-prm
  parentRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: nai-ingress-gateway
      namespace: nai-system
  backendRefs:
  - group: gateway.envoyproxy.io
    kind: Backend
    name: mcp-gh-remote
    namespace: nai-admin-extensions
    path: /mcp/
    toolSelector:
      include:
      - get_me
      - get_tag
  securityPolicy:
    oauth:
      issuer: "https://abc.okta.com/oauth2/default"
      jwks:
        remoteJWKS:
          uri: "https://abc.okta.com/oauth2/default/v1/keys"
      protectedResourceMetadata:
        scopesSupported:
          - "openid"
          - "email"
          - "profile"
Main HTTPRoute for the MCPRoute ai-eg-mcp-main-mcp-oauth-test-no-prm
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  labels:
  name: ai-eg-mcp-main-mcp-oauth-test-no-prm
spec:
  parentRefs:
  - group: gateway.networking.k8s.io
    kind: Gateway
    name: ingress-gateway
  rules:
  - backendRefs:
    - group: gateway.envoyproxy.io
      kind: Backend
      name: ai-eg-mcp-proxy
      namespace: nai-admin-extensions
      port: 9856
      weight: 1
    filters:
    - requestHeaderModifier:
        set:
        - name: x-ai-eg-mcp-route
          value: default/mcp-oauth-test-no-prm
      type: RequestHeaderModifier
    matches:
    - path:
        type: Exact
        value: /mcp/mcp-oauth-test-no-prm
    timeouts:
      backendRequest: 30m
      request: 30m
  - backendRefs:
    - group: gateway.envoyproxy.io
      kind: Backend
      name: ai-eg-mcp-proxy
      port: 9856
      weight: 1
    filters:
    - requestHeaderModifier:
        set:
        - name: x-ai-eg-mcp-route
          value: default/mcp-oauth-test-no-prm
      type: RequestHeaderModifier
    matches:
    - path:
        type: Exact
        value: /.well-known/oauth-protected-resource/mcp/mcp-oauth-test-no-prm
    name: oauth-protected-resource-metadata
  - filters:
    - extensionRef:
        group: gateway.envoyproxy.io
        kind: HTTPRouteFilter
        name: ai-eg-mcp-mcp-oauth-test-no-prm-oauth-authorization-server-metadata
      type: ExtensionRef
    matches:
    - path:
        type: Exact
        value: /.well-known/oauth-authorization-server/mcp/mcp-oauth-test-no-prm
    name: oauth-authorization-server-metadata
  - filters:
    - extensionRef:
        group: gateway.envoyproxy.io
        kind: HTTPRouteFilter
        name: ai-eg-mcp-mcp-oauth-test-no-prm-oauth-authorization-server-metadata
      type: ExtensionRef
    matches:
    - path:
        type: Exact
        value: /.well-known/openid-configuration/mcp/mcp-oauth-test-no-prm
    name: oauth-authorization-server-metadata-oidc
BTP ai-eg-mcp-mcp-oauth-test-no-prm-oauth-protected-resource-metadata
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
  name: ai-eg-mcp-mcp-oauth-test-no-prm-oauth-protected-resource-metadata
spec:
  responseOverride:
  - match:
      statusCodes:
      - type: Value
        value: 401
    response:
      header:
        set:
        - name: WWW-Authenticate
          value: Bearer error="invalid_token", error_description="The access token
            is missing or invalid", resource_metadata="%REQ(X-FORWARDED-PROTO?:SCHEME)%://%REQ(:AUTHORITY)%/.well-known/oauth-protected-resource/mcp/mcp-oauth-test-no-prm",
            scope="openid email profile"
        - name: Access-Control-Allow-Origin
          value: '*'
        - name: Access-Control-Allow-Methods
          value: GET
        - name: Access-Control-Allow-Headers
          value: mcp-protocol-version
      statusCode: 401
  targetRefs:
  - group: gateway.networking.k8s.io
    kind: HTTPRoute
    name: ai-eg-mcp-main-mcp-oauth-test-no-prm
Cursor MCP Config
    "mcp-aish-dev-oauth-test-no-prm-test": {
      "type": "streamable",
      "url": "https://<gw-base-url>/mcp/mcp-oauth-test-no-prm",
      "auth": {
        "CLIENT_ID": <>,
        "CLIENT_SECRET": <>,
        "scopes": ["openid", "profile", "email"]
      }
    }

3 Scenarios when PRM URL or PRM Doc is returned

Insufficient Scope

--- response ---
HTTP/1.1 403 Forbidden
content-type: text/plain; charset=utf-8
www-authenticate: Bearer error="insufficient_scope", scope="get_me",resource_metadata="https:///.well-known/oauth-protected-resource/mcp/mcp-oauth-test-no-prm", error_description="The token is missing required scopes"
date: Sat, 24 Oct 2026 07:34:01 GMT
content-length: 13
connection: close

access denied

Unauthenticated MCP initialize
==============================================================================
POST https://<gw-base-url>/mcp/mcp-oauth-test-no-prm
------------------------------------------------------------------------------
--- request ---
POST /mcp/mcp-oauth-test-no-prm HTTP/1.1
Accept: application/json, text/event-stream
Content-Type: application/json
Host: <gw-base-url>
Accept-Encoding: identity
Connection: close
Content-Length: 183

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "capabilities": {},
    "clientInfo": {
      "name": "mcp-oauth-handshake",
      "version": "0.1.0"
    }
  }
}
------------------------------------------------------------------------------
--- response ---
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token", error_description="The access token is missing or invalid", resource_metadata="https://<gw-base-url>/.well-known/oauth-protected-resource/mcp/mcp-oauth-test-no-prm", scope="openid email profile"
access-control-allow-origin: *
access-control-allow-methods: GET
access-control-allow-headers: mcp-protocol-version
date: Thu, 15 Oct 2026 13:49:00 GMT
connection: close
content-length: 0
Protected resource metadata discovery
==============================================================================
GET https://<gw-base-url>/.well-known/oauth-protected-resource/mcp/mcp-oauth-test-no-prm
------------------------------------------------------------------------------
--- request ---
GET /.well-known/oauth-protected-resource/mcp/mcp-oauth-test-no-prm HTTP/1.1
Accept: application/json
MCP-Protocol-Version: 2025-11-25
Host: <gw-base-url>
Accept-Encoding: identity
Connection: close

<empty>
------------------------------------------------------------------------------
--- response ---
HTTP/1.1 200 OK
access-control-allow-headers: mcp-protocol-version
access-control-allow-methods: GET
access-control-allow-origin: *
content-type: application/json
vary: X-Forwarded-Proto
date: Thu, 15 Oct 2026 13:49:02 GMT
content-length: 258
connection: close

{
    "authorization_servers": [
        "https://abc.okta.com/oauth2/default"
    ],
    "bearer_methods_supported": [
        "header"
    ],
    "scopes_supported": [
        "openid",
        "email",
        "profile"
    ],
    "resource": "https://<gw-base-url>/mcp/mcp-oauth-test-no-prm"
}
==============================================================================

404, if Authority doesn't belong to Hostnames
--- response ---
HTTP/1.1 404 Not Found
date: Sat, 24 Oct 2026 07:50:33 GMT
connection: close
content-length: 0

Similarly, responses returned by all other paths in the Main HTTPRoute were verified.

  • /.well-known/oauth-authorization-server/mcp/mcp-oauth-test-no-prm
  • /.well-known/openid-configuration/mcp/mcp-oauth-test-no-prm

Also tested MCPRoute with Protected Resource Metadata configured with Cursor MCP Client.

The OAuth Protected Resource Metadata document (RFC 9728) was served as a
static HTTPRouteFilter direct response, so its "resource" identifier had to be
known when the HTTPRoute was generated. That forced operators to configure
protectedResourceMetadata.resource by hand, duplicating the gateway's own
external URL, and made the field impossible to omit correctly: the control
plane cannot see the scheme, authority or port a client will actually use.

Serve the document from the MCP proxy instead. The well-known route rule now
forwards to the shared MCP proxy Backend with the same x-ai-eg-mcp-route header
the main rule sets, so the proxy resolves the route's OAuth configuration and
computes the identifier from the request: X-Forwarded-Proto for the scheme, the
preserved downstream authority for host and port, and the original path. The
403 insufficient_scope challenge is built the same way.

resource becomes optional, and an explicitly configured value still wins, for
deployments fronted by something that rewrites the external URL without
forwarding headers.

The 401 challenge is the one value the proxy cannot produce, since Envoy's JWT
filter rejects the request before it is proxied. It uses an Envoy substitution
format string, which Envoy Gateway passes through to the local response
policy's response_headers_to_add.

Signed-off-by: Aishwarya <aishraimule@gmail.com>
Unit tests for the derivation itself (scheme from the forwarded protocol, host
and port from the authority, path from the request), for an explicitly
configured resource still winning, and for the metadata document the MCP proxy
now serves. One config is exercised against several hosts to pin down the
property the old static document could not have: the same MCPRoute advertises
the right identifier on every address it is reachable on.

Also covers the upgrade path, where a stale HTTPRouteFilter left by an older
controller is removed, and asserts that a client cannot steer the proxy by
supplying x-ai-eg-original-path itself, since nothing strips that header from
inbound requests.

The e2e OAuth fixture now omits resource, so it exercises the derived path
end to end: it reaches the gateway through a port-forward on a port chosen at
run time, which no statically configured identifier could have matched. That
also puts the one value this change cannot compute in Go, the 401 challenge
Envoy expands from a substitution format string, under test in CI.
mcp_route_authorization.yaml keeps resource pinned to cover the override path.

Examples and the MCP capability docs drop the hand-written identifier, which in
the Keycloak example was a hardcoded http://127.0.0.1:1975/mcp.

Signed-off-by: Aishwarya <aishraimule@gmail.com>
@netlify

netlify Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for theagentrouter ready!

Name Link
🔨 Latest commit 806a3f3
🔍 Latest deploy log https://app.netlify.com/projects/theagentrouter/deploys/6aee0187826d8df4afdd435d
😎 Deploy Preview https://deploy-preview-2691--theagentrouter.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov

codecov Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.18310% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/mcpproxy/oauth.go 95.23% 3 Missing ⚠️
internal/controller/mcp_route_security_policy.go 96.96% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@aish1331 aish1331 changed the title Derive resource url feat: protectedResourceMetadata.resource from the request instead of requiring it to be configured Sep 14, 2026
@missBerg missBerg added enhancement New feature or request area/mcp MCP proxy, MCPRoute, and MCP spec conformance area/api Control plane API (CRDs) labels Sep 16, 2026
@aish1331
aish1331 marked this pull request as ready for review September 18, 2026 08:08
@aish1331
aish1331 requested a review from a team as a code owner September 18, 2026 08:08
@nacx nacx self-assigned this Sep 21, 2026
@missBerg missBerg added this to the v1.2 milestone Sep 21, 2026
@missBerg

Copy link
Copy Markdown
Contributor

Direction is right, and the 401 substitution works end to end per the e2e log. Three things before this can land:

  1. The metadata endpoint times out in e2e on all three EG versions. modifyMCPGatewayGeneratedCluster in internal/extensionserver/mcproute.go only rewrites the rule/0 cluster to the in-process proxy, so the new well-known rule's cluster still points at the dummy IP 192.0.2.42. maybeUpdateMCPRoutes has the same rule/0 check. Match on the dummy-IP endpoint instead of the rule index. Until this is fixed the proxy-served document and the derived 403 path are unverified.

  2. Derived resource must agree with audiences. Clients send the advertised resource as the RFC 8707 resource parameter and the AS binds aud to it, but audiences is still static. A token minted for http://127.0.0.1:1975/mcp fails validation against https://api.example.com/mcp. The docs need to say this, and "nothing to keep in sync" is overclaiming when audiences are set.

  3. Docs say Envoy sanitizes Host by default. It does not. Only X-Forwarded-Proto is sanitized. Low impact, but the sentence should be corrected.

remoteJWKS:
uri: http://localhost:8080/realms/master/protocol/openid-connect/certs
protectedResourceMetadata:
resource: "http://127.0.0.1:1975/mcp"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: do not remove here, and add a new example?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Both mcp_oauth_keycloak.yaml and mcp_oauth_example.yaml keep their pinned resource values as on main. The Keycloak example now also has a second MCPRoute, mcp-route-derived-resource on /mcp-derived, with resource omitted, so the same aigw run shows the derived identifier next to the pinned one. The README mentions the new route.

Comment on lines +541 to +545
// The OAuth protected resource metadata document used to be served by an HTTPRouteFilter
// direct response. It is now served by the MCP proxy, which can derive the resource
// identifier from the request, so delete the filter left behind by an older version.
if delErr := c.deleteOAuthProtectedResourceMetadataHRF(ctx, mcpRoute); delErr != nil {
return fmt.Errorf("failed to delete legacy HTTPRouteFilter: %w", delErr)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: can add a todo to remove this block after 2.0?

Comment thread internal/filterapi/mcpconfig.go Outdated
Comment on lines +49 to +51
// When set, the MCP proxy serves the protected resource metadata document and includes
// a resource_metadata challenge in WWW-Authenticate headers it emits.
OAuth *MCPRouteOAuth `json:"oauth,omitempty"`

@Hritik003 Hritik003 Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

if this represents only PRM, can we rename it to ProtectedResourceMetadata, since the present name sounds confusing

Comment on lines +316 to +320
// Reference: https://www.envoyproxy.io/docs/envoy/latest/configuration/observability/access_log/usage#command-operators
func envoyDerivedResourceMetadataURL(servingPath string) string {
return fmt.Sprintf("%%REQ(X-FORWARDED-PROTO)%%://%%REQ(:AUTHORITY)%%%s%s",
oauthWellKnownProtectedResourceMetadataPath, strings.TrimSuffix(servingPath, "/"))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

qq: if we already have the url info here, why not hardcode the same in the HRF without the mcpproxy impl this PR suggests?

is it because clients would then get the literal string:

%REQ(X-FORWARDED-PROTO)%://%REQ(:AUTHORITY)%/.well-known/oauth-protected-resource/mcp
inside the JSON of PRM response and envoy does not expand command operators in that body?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, that's exactly it.

Envoy expands command operators only where the config field is a format string: access log
formats, request_headers_to_add / response_headers_to_add values, and
local_reply_config.body_format. The 401 challenge works because it lands in the second of
those — Envoy Gateway funnels a ResponseOverride header value into the local response
policy's response_headers_to_add.

A direct response body is not in that set. HTTPRouteFilter.directResponse.body.inline
becomes direct_response.body, which is a DataSource (an opaque inline string), so
%REQ(:AUTHORITY)% would be served to the client verbatim inside the JSON.

Two further reasons it wouldn't work even if the body were templated:

  1. The derivation has to exist in Go regardless. The 403 insufficient_scope challenge is
    emitted by the MCP proxy, not by Envoy. It used to read a ResourceMetadataURL precomputed
    at config time from the pinned resource; once the identifier is no longer static, the
    proxy has to compute it from the request it is handling. So resourceMetadataURL() exists
    either way — serving the document from the proxy reuses it instead of maintaining a second,
    templated copy of the same logic in the HTTPRouteFilter.

  2. Escaping. Splicing %REQ(:AUTHORITY)% into a JSON string literal is an injection
    surface: a quote in Host terminates the literal and the attacker picks the next JSON key —
    authorization_servers, say. Envoy's JSON-safe interpolation lives in
    SubstitutionFormatString.json_format, which direct_response cannot use. On the other hand, building the
    document in Go means json.Marshal escapes it. The e2e test with Host: evil"injected
    pins this for both surfaces.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Example for the JSON Injection Surface:

Concretely. The template the HTTPRouteFilter would carry (keys sorted, as json.Marshal of a
map emits them, with the identifier templated):

{"authorization_servers":["https://auth.example.com"],"bearer_methods_supported":["header"],"resource":"%REQ(X-FORWARDED-PROTO)%://%REQ(:AUTHORITY)%/mcp","resource_name":"example-resource","scopes_supported":["echo"]}

An attacker sends:

GET /.well-known/oauth-protected-resource/mcp HTTP/1.1
Host: victim.example.com","authorization_servers":["https://evil.example.com"],"x":"

%REQ(:AUTHORITY)% is substituted literally, so the body on the wire becomes:

{"authorization_servers":["https://auth.example.com"],"bearer_methods_supported":["header"],"resource":"http://victim.example.com","authorization_servers":["https://evil.example.com"],"x":"/mcp","resource_name":"example-resource","scopes_supported":["echo"]}

That is still syntactically valid JSON, which is what makes it nasty — nothing downstream
errors out. It just has authorization_servers twice, and every mainstream parser (Go's
encoding/json, JSON.parse, Python's json) is last-wins, so the client reads
["https://evil.example.com"] and runs its authorization-code flow against the attacker's
server.

resource sorting after authorization_servers is the whole reason the second declaration
wins. So whether this is exploitable depends on Go's map key ordering and on the parser's
duplicate-key policy — accidents, not defenses.

Building the document in Go (MCP Proxy Code) removes the question. The same Host produces:

{"resource":"http://victim.example.com\",\"authorization_servers\":[\"https://evil.example.com\"],\"x\":\"/mcp", ...}

one string value, escaped by json.Marshal, no structural change.

NOTE: To be clear about today's state: Envoy rejects an authority containing " long before any of
this, so the attack does not land against either implementation — that is what the
Host: evil"injected e2e test asserts.
The point of doing it in Go is that Envoy's authority validation stops being the only thing standing between a request header and the contents of the discovery document.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This format string has no safe degradation, and it is the only path unauthenticated clients ever see. The 401 is what every client hits before it has a token, so a failure here breaks discovery completely rather than partially.

Two ways it degrades badly:

  • No X-Forwarded-Proto. Envoy's substitution formatter renders an absent header as -, so the client receives resource_metadata="-://host/.well-known/...". Note the Go path in internal/mcpproxy/oauth.go:45-63 has a r.TLS fallback for exactly this; the Envoy path has nothing equivalent.
  • No expansion. If the value is not run through a substitution formatter, the client receives the literal %REQ(X-FORWARDED-PROTO)%://%REQ(:AUTHORITY)%/.... The path does exist in the pinned EG version (envoyproxy/gateway@v1.9.0 internal/xds/translator/custom_response.go:586 → route.go:695 buildXdsAddedHeaders → LocalResponsePolicy.ResponseHeadersToAdd), but this repo supports several EG versions and only a running Envoy proves it, as you say in the PR body.

The e2e assertion on fwd.Address() (the 401 challenge subtest under resource identifier derived from the request) is the right guard and should be kept running against every supported EG version, not just the newest.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The format string now uses Envoy's alternate-header syntax, %REQ(X-FORWARDED-PROTO?:SCHEME)%, so if X-Forwarded-Proto is missing the scheme comes from the request's :scheme pseudo-header and never renders as -. That brings the Envoy path in line with the r.TLS fallback in externalScheme on the Go side. In practice the header should always be there: Envoy's connection manager sets X-Forwarded-Proto on the request before the filter chain runs, so the JWT filter's 401 already sees it. The fallback is a backstop.

On expansion across versions: the 401 challenge subtest under resource identifier derived from the request checks the expanded resource_metadata against fwd.Address(), has no version skip, and the E2E matrix runs it on Envoy Gateway latest, v1.9.2 and v1.8.5. If any supported version stopped expanding the operators, the client would see the literal format string and that job would fail.

Comment thread internal/mcpproxy/oauth.go Outdated
Comment on lines +140 to +144
if r.Method == http.MethodOptions {
writeProtectedResourceMetadataCORSHeaders(w.Header())
w.WriteHeader(http.StatusNoContent)
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

qq: this is purely for browser clients like mcp inspector?

Comment thread tests/e2e/testdata/mcp_route_oauth.yaml Outdated
Comment on lines 59 to 64
# resource is deliberately omitted: the gateway derives the identifier from the
# scheme, authority and path of each request. This test reaches the gateway through a
# port-forward on a port picked at run time, so no statically configured value could
# ever match. mcp_route_authorization.yaml pins resource to cover the override path.
resourceName: "example-resource"
scopesSupported:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: can let this be, and create another tc without resource url?

Signed-off-by: Aishwarya <aishraimule@gmail.com>
Serving the protected resource metadata from the MCP proxy changed four
things a client can observe, none of them intended. An MCPRoute that pins
protectedResourceMetadata.resource must behave exactly as it did when the
document was an HTTPRouteFilter direct response.

CORS: ensureCORSHeaders advertised "GET" and "mcp-protocol-version". The
handler had narrowed these to "GET, OPTIONS" and "content-type", which would
reject the preflight of a browser MCP client sending mcp-protocol-version.

Methods: the rule matched on path alone, so Envoy answered every method with
the document. The handler had added a method gate returning 405, and a
separate 204 path for OPTIONS.

Trailing slash: the direct response emitted the configured value unchanged.
The document now does the same. The challenge URL still normalizes it, as
buildResourceMetadataURL always has, so the two agree on the resource while
the document stays byte-identical to what the field was set to.

Vary is added only when the identifier is derived from the request, since
that is the only case where the body varies by Host and X-Forwarded-Proto. A
pinned resource yields a request-independent response, exactly as before, and
a shared cache in front of it stays correct without the header.

Signed-off-by: Aishwarya <aishraimule@gmail.com>
…metadata

The logic for setting the Vary header in the response for OAuth protected resource metadata has been refined. The Vary header now only includes "X-Forwarded-Proto" when the resource is not pinned, as the Host is already part of the effective request URI used for caching. This change ensures that the response remains consistent with previous behavior while improving cache efficiency.

Additionally, the obsolete derivesFromRequest function has been removed to streamline the code.

Signed-off-by: Aishwarya <aishraimule@gmail.com>
… routes

This commit introduces a new test case to ensure that the gateway correctly rejects Host headers that could break out of the expected syntax. The test checks both the 401 challenge and the metadata document for potential injection vulnerabilities. A helper function, rawRequestWithHost, is also added to facilitate sending requests with specific Host headers while capturing the raw response for validation.

Signed-off-by: Aishwarya <aishraimule@gmail.com>
…ix documentation to resolve comments

Signed-off-by: Aishwarya <aishraimule@gmail.com>
Signed-off-by: Aishwarya <aishraimule@gmail.com>
…rive-resource-url

Signed-off-by: Aishwarya <aishraimule@gmail.com>
Signed-off-by: Aishwarya <aishraimule@gmail.com>
Comment on lines +316 to +320
// Reference: https://www.envoyproxy.io/docs/envoy/latest/configuration/observability/access_log/usage#command-operators
func envoyDerivedResourceMetadataURL(servingPath string) string {
return fmt.Sprintf("%%REQ(X-FORWARDED-PROTO)%%://%%REQ(:AUTHORITY)%%%s%s",
oauthWellKnownProtectedResourceMetadataPath, strings.TrimSuffix(servingPath, "/"))
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This format string has no safe degradation, and it is the only path unauthenticated clients ever see. The 401 is what every client hits before it has a token, so a failure here breaks discovery completely rather than partially.

Two ways it degrades badly:

  • No X-Forwarded-Proto. Envoy's substitution formatter renders an absent header as -, so the client receives resource_metadata="-://host/.well-known/...". Note the Go path in internal/mcpproxy/oauth.go:45-63 has a r.TLS fallback for exactly this; the Envoy path has nothing equivalent.
  • No expansion. If the value is not run through a substitution formatter, the client receives the literal %REQ(X-FORWARDED-PROTO)%://%REQ(:AUTHORITY)%/.... The path does exist in the pinned EG version (envoyproxy/gateway@v1.9.0 internal/xds/translator/custom_response.go:586 → route.go:695 buildXdsAddedHeaders → LocalResponsePolicy.ResponseHeadersToAdd), but this repo supports several EG versions and only a running Envoy proves it, as you say in the PR body.

The e2e assertion on fwd.Address() (the 401 challenge subtest under resource identifier derived from the request) is the right guard and should be kept running against every supported EG version, not just the newest.

if metadata.Resource != "" {
resourceMetadataURL = buildResourceMetadataURL(metadata.Resource)
} else {
resourceMetadataURL = envoyDerivedResourceMetadataURL(servingPath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Three code paths now choose between a pinned and a derived identifier, each with its own copy of the Resource != "" test and its own URL-splicing logic: here, resourceIdentifier (internal/mcpproxy/oauth.go:79-87) and resourceMetadataURL (internal/mcpproxy/oauth.go:93-110). The splicing in buildResourceMetadataURL above and in resourceMetadataURL is the same algorithm written twice, in two packages.

Currently the controller cannot import mcpproxy and one of the three has to emit Envoy operators rather than a URL, but it means a future change to how the well-known path is spliced has to be made in two places, and the tests that pin them (Test_buildWWWAuthenticateHeaderValue and TestResourceMetadataURL) live in different packages and could drift apart silently. A shared helper in a package both can import, or at minimum a comment in each pointing at the other, would make the coupling visible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

moved the common code to a helper in internalapi package

// filter exists, so it costs a single cached Get per reconcile after an upgrade.
func (c *MCPRouteController) deleteOAuthProtectedResourceMetadataHRF(ctx context.Context, mcpRoute *aigv1b1.MCPRoute) error {
var httpRouteFilter egv1a1.HTTPRouteFilter
name := oauthProtectedResourceMetadataName(mcpRoute.Name)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nit] This is upgrade-path code that will run forever. The comment is right that it costs one cached Get per reconcile, which is cheap, but nothing records when it can be deleted. Can you just add a TODO comment to remove it in version X, or even mark the method as deprecated so we notice it in future releases and remember to remove?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have added a code comment to deprecate this after 2.0. This is tricky because if user upgrades from <=1.2 to 2.1, they will end up with a stale HRF resource. The resource is harmless in itself since it is not referenced. But deprecating after 2.0 release (which should be atleast a few months away) is alright since most active users would have run atleast one upgrade by then. Wdyt?

// Resource, when non-empty, pins the resource identifier instead of deriving it from the
// request. Set it only when the externally visible URL cannot be recovered from the
// request, e.g. behind a proxy that rewrites the authority without forwarding headers.
Resource string `json:"resource,omitempty"`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

compiledAuthorization.same no longer compares ResourceMetadataURL (internal/mcpproxy/authorization.go:47-53), and prm is not compared by sameTools (internal/mcpproxy/config.go:121-149) either. sameTools drives the notifications/tools/list_changed signal, so leaving OAuth metadata out of it is correct. A changed issuer is not a tool change. But it does mean nothing in the reload path compares prm at all. That is fine today because LoadConfig replaces the route wholesale (config.go:287-291), so the new value takes effect regardless; it is worth a one-line comment on prm saying so, since the neighbouring fields are all part of a comparison and this one deliberately is not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done, added a comment


// TestServePOST_InitializeRequest_NegotiatesProtocolVersion verifies that the gateway
// returns max(protocolVersion20250618, min(clientVersion, min(backends))) end-to-end.
func TestServePOST_InitializeRequest_NegotiatesProtocolVersion(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This test is present at the merge-base (7d7c07ff) and on current upstream/main, and nothing in this PR touches protocol negotiation. mergedProtocolVersion, protocolVersion20260728 and the initialize handshake are all untouched here.

This is almost certainly a mis-resolved merge from main: the branch carries several Merge branch 'main' commits. The remaining coverage in internal/mcpproxy/modern_test.go:1213,1315,1375 exercises mergedProtocolVersion as a unit, but not the end-to-end servePOST path this test covered. So the deletion is a silent, unrelated coverage regression that CI will not flag.

Restore both the test and the BackendSelectorDenied doc comment, and re-check the rest of the diff for other collateral from the same merge.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

reverted and verified there is no collateral from that merge

// byte-identical document before and after this change.
return prm.Resource
}
identifier := externalScheme(r) + "://" + r.Host + resourcePath

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

r.Host goes into the advertised RFC 9728 resource identifier with no validation and no allow-list. The doc update is honest about this (site/docs/capabilities/mcp/index.md:368: Envoy does not sanitize Host, ignores the port when matching a listener, and a wildcard hostname admits any subdomain), and an operator can pin resource. But the code has no defence of its own, so the safe configuration is entirely a documentation contract.

The new e2e (identifier tracks the request authority) sends well-formed foreign authorities (api.example.com, tenant-b.example.com:8443) and asserts a 200 that advertises them, so it pins this as intended behaviour. Concretely: on a listener with no hostname, Host: attacker.example.com yields a 200 document advertising resource: "https://attacker.example.com/mcp" from an endpoint that requires no token to reach.

What that buys an attacker is narrow. authorization_servers still comes from config, so the AS cannot be redirected, and a spec-conforming client rejects an identifier that does not match the URL it used. But it is unauthenticated, attacker-controlled content in a security document, and there is a cheap fix: when prm.Resource is empty, check the request authority against the MCPRoute's hostnames (already carried onto the HTTPRoute at internal/controller/mcp_route.go:484) and fall back to 404 or to the configured host on a mismatch. At minimum, add a case asserting what happens for a Host outside the MCPRoute's hostnames, so the behaviour is a deliberate decision rather than an untested side effect.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The well-known rule is on the same generated HTTPRoute as the MCP endpoint and gets spec.hostnames, so Envoy's host matching already rejects a Host outside the list before the proxy sees it. The new e2e test 'derived identifier is limited to the route hostnames' shows this. A route without hostnames is reachable on every host its listener accepts, and the identifier reflects whichever one was used; the Resource field docs now say so.

// Both mean there is no metadata document to serve for it.
m.l.Debug("no OAuth configuration for MCP route, not serving protected resource metadata",
"route", routeName, "path", externalPath(r))
http.NotFound(w, r)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This 404 is a new failure mode. Previously the document was an Envoy direct response: once the HTTPRoute was programmed it was served unconditionally, with no dependency on the extproc process or on filter-config propagation. Now the endpoint returns 404 whenever m.routes[routeName] is not yet populated. During an extproc restart, or in the window between the HTTPRoute being programmed and LoadConfig receiving the config (internal/mcpproxy/config.go:287-291).

A client that gets a 404 from /.well-known/oauth-protected-resource treats the resource as having no metadata and abandons the OAuth flow; it does not retry the way it would on a 503. Returning 503 for "route unknown to this proxy instance" and reserving 404 for "route exists but configures no OAuth" would let clients back off correctly, and the two cases are already distinguishable here. m.routes[routeName] == nil versus route.prm == nil.

This is worth a line in the release notes regardless: the discovery endpoint's availability is now coupled to the extproc sidecar, which it was not before.

	route := m.routes[routeName]
	if route == nil {
		// Config not loaded yet (or no longer). Tell the client to retry rather than
		// letting it conclude the resource has no metadata.
		http.Error(w, "service unavailable", http.StatusServiceUnavailable)
		return
	}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

Comment thread internal/mcpproxy/oauth.go Outdated
// honoring it would let a client choose which document the proxy serves and which path the
// advertised resource identifier names.
func externalPath(r *http.Request) string {
return r.URL.Path

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

r.URL.Path is the percent-decoded path, and it is spliced verbatim into the resource value the client is required to compare for exact string equality. A client that reached /.well-known/oauth-protected-resource/ten%61nt/mcp is handed back .../tenant/mcp and the comparison fails on its side.

In practice Envoy exact-matches the well-known path (internal/controller/mcp_route.go:358-361) so the decoded and raw forms normally coincide. But r.URL.EscapedPath() reproduces the bytes the client sent and removes the coupling between what isProtectedResourceMetadataRequest matched and what strings.TrimPrefix later slices. Worth doing while the function is one line.

func externalPath(r *http.Request) string {
	return r.URL.EscapedPath()
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Comment thread internal/mcpproxy/oauth.go Outdated
// needs no Vary: it is already part of the effective request URI a cache keys on.
// Omitted when resource is pinned: that response is request-independent, exactly as
// the static direct response was, and the header would itself be a behaviour change.
h.Set("Vary", "X-Forwarded-Proto")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The reasoning that Host needs no Vary is correct for an RFC 9111 cache, which keys on the effective request URI. It does not hold for the deployment shape this feature is aimed at: a CDN or shared front-door in front of a single-hostname gateway commonly keys /.well-known/* on path alone, and this response body now varies by authority where the direct response it replaced did not.

Since the identifier is derived precisely so that one config serves many hostnames, the multi-hostname case is the intended one, and a poisoned entry hands every client an identifier naming someone else's host. Cache-Control: no-store on the derived branch would be the cheaper and more definitive answer than reasoning about cache key semantics. The document is trivial to generate and there is nothing to gain from caching it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

…th internal API constant

Signed-off-by: Aishwarya <aishraimule@gmail.com>
Signed-off-by: Aishwarya <aishraimule@gmail.com>
…ient

Signed-off-by: Aishwarya <aishraimule@gmail.com>
…dvertised list, set Cache-Control to no-store in response to avoid caching where it is indexed by path

Signed-off-by: Aishwarya <aishraimule@gmail.com>
Signed-off-by: Aishwarya <aishraimule@gmail.com>
Signed-off-by: Aishwarya <aishraimule@gmail.com>
@aish1331

Copy link
Copy Markdown
Contributor Author

/retest

@aish1331

Copy link
Copy Markdown
Contributor Author

/retest

@aish1331

Copy link
Copy Markdown
Contributor Author

/retest

@aish1331

aish1331 commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

/retest

1 similar comment
@aish1331

aish1331 commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

/retest

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api Control plane API (CRDs) area/mcp MCP proxy, MCPRoute, and MCP spec conformance enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Derive protectedResourceMetadata.resource from the request instead of requiring it to be configured

4 participants