Skip to content

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

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

aish1331 wants to merge 17 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 becomes optional. When omitted, the identifier is
derived per request from the forwarded scheme, the authority and the path the client used, so
one MCPRoute stays correct on every hostname and port it is reachable on. An explicitly
configured value still wins, for deployments fronted by something that rewrites the external
URL without forwarding headers.
Three places advertise the identifier, and each derives it where it can:

Surface Produced by How
Metadata document MCP proxy X-Forwarded-Proto + Host + request path
403 insufficient_scope challenge MCP proxy same, via resourceMetadataURL
401 invalid_token challenge Envoy substitution format string in the BackendTrafficPolicy response override

The 401 is the one value the proxy cannot compute: Envoy's JWT filter rejects the request
before it is ever proxied, so the ResponseOverride header carries
%REQ(X-FORWARDED-PROTO)%://%REQ(:AUTHORITY)%/.well-known/..., which Envoy Gateway passes
through to the local response policy's response_headers_to_add.

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, populated whenever securityPolicy.oauth is set, since 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; the challenge URL keeps normalizing it as buildResourceMetadataURL always
    did. The CORS headers and the "answer any method" behaviour of the old direct response are
    preserved. Vary: X-Forwarded-Proto is set only in the derived case, since a pinned response
    is request-independent.
  • Docs. New sections on the resource identifier and on audience validation — specifically
    that an RFC 8707 client sends the advertised identifier as the resource parameter, so a
    derived identifier has to agree with a statically configured audiences list.

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. There is
a test for this, and the delete costs one cached Get per reconcile once nothing is left to
remove.

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 docs now 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 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)% 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.Path 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.

tests/e2e/testdata/mcp_route_oauth.yaml now deliberately omits resource, so the e2e suite
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 ever have matched.
tests/e2e/testdata/mcp_route_authorization.yaml keeps resource pinned, so the override path
stays covered.

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 canceled.

Name Link
🔨 Latest commit dce1983
🔍 Latest deploy log https://app.netlify.com/projects/theagentrouter/deploys/6ababe72cfd5d20008d85808

@codecov

codecov Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.85714% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/mcpproxy/oauth.go 95.58% 3 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?

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 361 to 367
// * https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response
func buildWWWAuthenticateHeaderValue(metadata *aigv1b1.ProtectedResourceMetadata) string {
resourceMetadataURL := buildResourceMetadataURL(metadata)
func buildWWWAuthenticateHeaderValue(metadata *aigv1b1.ProtectedResourceMetadata, servingPath string) string {
resourceMetadataURL := envoyDerivedResourceMetadataURL(servingPath)
if metadata.Resource != "" {
resourceMetadataURL = buildResourceMetadataURL(metadata.Resource)
}
headerValue := `Bearer error="invalid_token", error_description="The access token is missing or invalid"`

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.

we already have the PRM metadata, so we should prefer metadata.Resource and only fall back to the derived URL when it's empty

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.

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>
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