From 2eff165ad50cb484e55977d0e86d7fc7f3ab3f55 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 7 Sep 2026 03:42:48 +0000 Subject: [PATCH 1/3] docs(schemas): describe multi-value handling on forward_client_headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `forward_client_headers` appears on four resources, and its handling of a header the caller sent more than once was described nowhere a user reads. The two code paths behind the field also differ, and the difference was equally undocumented: - `provider_key`, `mcp_server` and `a2a_agent` resolve the forwarded set through `resolve_forwarded_client_headers`, which walks `HeaderMap::keys()` and takes `get(name)` — the first value only. - `passthrough_route` builds its outbound request by iterating the inbound `HeaderMap` directly, which yields one pair per value, and appends each — every value is preserved. Each field description now states its own surface's behavior, so all four are accurate rather than uniform. The `provider_key` / `mcp_server` / `a2a_agent` text also names the case an operator is most likely to meet it in: an HTTP/2 caller may split `cookie` across several header fields, and only the first is forwarded. Doc comments only, plus the schemas regenerated from them with `cargo run -p aisix-core --bin dump-schema`. No forwarding behavior changes. --- crates/aisix-core/src/models/a2a_agent.rs | 6 ++++++ crates/aisix-core/src/models/mcp_server.rs | 6 ++++++ crates/aisix-core/src/models/passthrough_route.rs | 3 +++ crates/aisix-core/src/models/provider_key.rs | 6 ++++++ schemas/resources/a2a_agent.schema.json | 2 +- schemas/resources/mcp_server.schema.json | 2 +- schemas/resources/passthrough_route.schema.json | 2 +- schemas/resources/provider_key.schema.json | 2 +- 8 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/aisix-core/src/models/a2a_agent.rs b/crates/aisix-core/src/models/a2a_agent.rs index 011bf847..98c1d333 100644 --- a/crates/aisix-core/src/models/a2a_agent.rs +++ b/crates/aisix-core/src/models/a2a_agent.rs @@ -93,6 +93,12 @@ pub struct A2aAgent { /// method served at `/a2a/`, so an agent receives them on /// `message/send`, `message/stream` and every task operation alike. /// + /// A header the caller sends more than once is forwarded with its first + /// value only; the upstream receives one well-formed header rather than a + /// list this gateway never interpreted. An HTTP/2 caller may split + /// `cookie` across several header fields, and only the first of them is + /// forwarded. + /// /// A header named here reaches the agent whatever the gateway would /// otherwise do with it. Naming the credential slot `auth_type` would /// fill — `authorization` for `bearer`, `x-api-key` for `api_key` — hands diff --git a/crates/aisix-core/src/models/mcp_server.rs b/crates/aisix-core/src/models/mcp_server.rs index 3d26630b..1a1d2032 100644 --- a/crates/aisix-core/src/models/mcp_server.rs +++ b/crates/aisix-core/src/models/mcp_server.rs @@ -124,6 +124,12 @@ pub struct McpServer { /// nothing. Applies to both `type: mcp` and `type: openapi`, so a REST /// API exposed here as tools receives them on every tool call. /// + /// A header the caller sends more than once is forwarded with its + /// first value only; the upstream receives one well-formed header + /// rather than a list this gateway never interpreted. An HTTP/2 + /// caller may split `cookie` across several header fields, and only + /// the first of them is forwarded. + /// /// A header named here reaches the server whatever the gateway would /// otherwise do with it. Naming the credential slot `auth_type` would /// fill — `authorization` for `bearer` and `oauth2`, `api_key_header` diff --git a/crates/aisix-core/src/models/passthrough_route.rs b/crates/aisix-core/src/models/passthrough_route.rs index acd6b896..5cd697b2 100644 --- a/crates/aisix-core/src/models/passthrough_route.rs +++ b/crates/aisix-core/src/models/passthrough_route.rs @@ -130,6 +130,9 @@ pub struct PassthroughRoute { /// (`"authorization"`, `"x-trace-*"`). Empty — the default — overrides /// no stripping. /// + /// A header the caller sends more than once is forwarded with every + /// value preserved. + /// /// A route forwards the caller's headers by default, so this field /// only matters for the ones it removes: the ProviderKey's /// `strip_headers` under `credential_mode: inject`, and the slot the diff --git a/crates/aisix-core/src/models/provider_key.rs b/crates/aisix-core/src/models/provider_key.rs index 04e75ec4..a4f02d08 100644 --- a/crates/aisix-core/src/models/provider_key.rs +++ b/crates/aisix-core/src/models/provider_key.rs @@ -370,6 +370,12 @@ pub struct RequestOverrides { /// default — forwards nothing, which is the behavior of every /// standard-protocol endpoint before AISIX-Cloud#1167. /// + /// A header the caller sends more than once is forwarded with its + /// first value only; the upstream receives one well-formed header + /// rather than a list this gateway never interpreted. An HTTP/2 + /// caller may split `cookie` across several header fields, and only + /// the first of them is forwarded. + /// /// A header named here reaches the upstream whatever the gateway would /// otherwise do with it. Naming a credential slot — `authorization`, /// `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, diff --git a/schemas/resources/a2a_agent.schema.json b/schemas/resources/a2a_agent.schema.json index 82429826..b05d1f3d 100644 --- a/schemas/resources/a2a_agent.schema.json +++ b/schemas/resources/a2a_agent.schema.json @@ -137,7 +137,7 @@ "type": "boolean" }, "forward_client_headers": { - "description": "Inbound client headers forwarded to this agent, as single-`*` glob patterns matched case-insensitively against the header name (`\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing. Applies to the agent-card fetch at `/a2a//.well-known/agent-card.json` as well as to every JSON-RPC method served at `/a2a/`, so an agent receives them on `message/send`, `message/stream` and every task operation alike.\n\nA header named here reaches the agent whatever the gateway would otherwise do with it. Naming the credential slot `auth_type` would fill — `authorization` for `bearer`, `x-api-key` for `api_key` — hands the agent the caller's own credential in place of the gateway's, never both. That is what lets an internal agent that already authorizes on the end user's `Authorization` keep doing so unchanged. An agent that validates the `aud` claim will reject a token minted for the gateway.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, the gateway's `x-aisix-*` namespace, the headers describing a body this gateway re-serializes (`content-type`, `content-length`, `accept`), and `a2a-version`, which is the gateway's own announcement of the wire version pinned in `protocol_version` and which a caller's value would override.", + "description": "Inbound client headers forwarded to this agent, as single-`*` glob patterns matched case-insensitively against the header name (`\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing. Applies to the agent-card fetch at `/a2a//.well-known/agent-card.json` as well as to every JSON-RPC method served at `/a2a/`, so an agent receives them on `message/send`, `message/stream` and every task operation alike.\n\nA header the caller sends more than once is forwarded with its first value only; the upstream receives one well-formed header rather than a list this gateway never interpreted. An HTTP/2 caller may split `cookie` across several header fields, and only the first of them is forwarded.\n\nA header named here reaches the agent whatever the gateway would otherwise do with it. Naming the credential slot `auth_type` would fill — `authorization` for `bearer`, `x-api-key` for `api_key` — hands the agent the caller's own credential in place of the gateway's, never both. That is what lets an internal agent that already authorizes on the end user's `Authorization` keep doing so unchanged. An agent that validates the `aud` claim will reject a token minted for the gateway.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, the gateway's `x-aisix-*` namespace, the headers describing a body this gateway re-serializes (`content-type`, `content-length`, `accept`), and `a2a-version`, which is the gateway's own announcement of the wire version pinned in `protocol_version` and which a caller's value would override.", "items": { "type": "string" }, diff --git a/schemas/resources/mcp_server.schema.json b/schemas/resources/mcp_server.schema.json index e9b76280..26d5925e 100644 --- a/schemas/resources/mcp_server.schema.json +++ b/schemas/resources/mcp_server.schema.json @@ -307,7 +307,7 @@ "type": "boolean" }, "forward_client_headers": { - "description": "Inbound client headers forwarded to this server, as single-`*` glob patterns matched case-insensitively against the header name (`\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing. Applies to both `type: mcp` and `type: openapi`, so a REST API exposed here as tools receives them on every tool call.\n\nA header named here reaches the server whatever the gateway would otherwise do with it. Naming the credential slot `auth_type` would fill — `authorization` for `bearer` and `oauth2`, `api_key_header` for `api_key` — hands the server the caller's own credential in place of the gateway's, never both. That is what lets an internal server that already authorizes on the end user's `Authorization` keep doing so unchanged. A server that validates the `aud` claim will reject a token minted for the gateway.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, the gateway's `x-aisix-*` namespace, the headers describing a body this gateway re-serializes (`content-type`, `content-length`, `accept`), and the MCP session slots (`mcp-session-id`, `mcp-protocol-version`, `last-event-id`), which name the caller's session with this gateway and which an upstream MCP server refuses outright when they carry a foreign value.", + "description": "Inbound client headers forwarded to this server, as single-`*` glob patterns matched case-insensitively against the header name (`\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing. Applies to both `type: mcp` and `type: openapi`, so a REST API exposed here as tools receives them on every tool call.\n\nA header the caller sends more than once is forwarded with its first value only; the upstream receives one well-formed header rather than a list this gateway never interpreted. An HTTP/2 caller may split `cookie` across several header fields, and only the first of them is forwarded.\n\nA header named here reaches the server whatever the gateway would otherwise do with it. Naming the credential slot `auth_type` would fill — `authorization` for `bearer` and `oauth2`, `api_key_header` for `api_key` — hands the server the caller's own credential in place of the gateway's, never both. That is what lets an internal server that already authorizes on the end user's `Authorization` keep doing so unchanged. A server that validates the `aud` claim will reject a token minted for the gateway.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, the gateway's `x-aisix-*` namespace, the headers describing a body this gateway re-serializes (`content-type`, `content-length`, `accept`), and the MCP session slots (`mcp-session-id`, `mcp-protocol-version`, `last-event-id`), which name the caller's session with this gateway and which an upstream MCP server refuses outright when they carry a foreign value.", "items": { "type": "string" }, diff --git a/schemas/resources/passthrough_route.schema.json b/schemas/resources/passthrough_route.schema.json index e84cbe35..3c252183 100644 --- a/schemas/resources/passthrough_route.schema.json +++ b/schemas/resources/passthrough_route.schema.json @@ -485,7 +485,7 @@ "type": "boolean" }, "forward_client_headers": { - "description": "Inbound client headers forwarded to the upstream even when this route would otherwise strip them, as single-`*` glob patterns matched case-insensitively against the header name (`\"authorization\"`, `\"x-trace-*\"`). Empty — the default — overrides no stripping.\n\nA route forwards the caller's headers by default, so this field only matters for the ones it removes: the ProviderKey's `strip_headers` under `credential_mode: inject`, and the slot the gateway consumed to authenticate the caller. Naming `authorization` under `auth_mode: gateway_key` therefore puts the caller's own credential back on the upstream request in place of the one this route would inject, never both — which is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged.\n\nA credential slot — `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` — and `traceparent` / `tracestate` are forwarded only when a pattern names them exactly. A glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry, so a broad pattern overrides the rest of the strip set and leaves those alone.\n\nThis route's own `auth_header_name` and `identity_header` are read the same way. Both are slots this route chose rather than ones the gateway owns — under `auth_mode: header_key` the first carries the gateway credential the caller authenticated with, and the second carries an end-user identity this route records and strips — so a glob does not sweep either, and a pattern that names one in full forwards it.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are stripped whatever the patterns say: `host`, `content-length`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace.", + "description": "Inbound client headers forwarded to the upstream even when this route would otherwise strip them, as single-`*` glob patterns matched case-insensitively against the header name (`\"authorization\"`, `\"x-trace-*\"`). Empty — the default — overrides no stripping.\n\nA header the caller sends more than once is forwarded with every value preserved.\n\nA route forwards the caller's headers by default, so this field only matters for the ones it removes: the ProviderKey's `strip_headers` under `credential_mode: inject`, and the slot the gateway consumed to authenticate the caller. Naming `authorization` under `auth_mode: gateway_key` therefore puts the caller's own credential back on the upstream request in place of the one this route would inject, never both — which is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged.\n\nA credential slot — `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` — and `traceparent` / `tracestate` are forwarded only when a pattern names them exactly. A glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry, so a broad pattern overrides the rest of the strip set and leaves those alone.\n\nThis route's own `auth_header_name` and `identity_header` are read the same way. Both are slots this route chose rather than ones the gateway owns — under `auth_mode: header_key` the first carries the gateway credential the caller authenticated with, and the second carries an end-user identity this route records and strips — so a glob does not sweep either, and a pattern that names one in full forwards it.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are stripped whatever the patterns say: `host`, `content-length`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace.", "items": { "type": "string" }, diff --git a/schemas/resources/provider_key.schema.json b/schemas/resources/provider_key.schema.json index 7bb8065f..047b5a61 100644 --- a/schemas/resources/provider_key.schema.json +++ b/schemas/resources/provider_key.schema.json @@ -129,7 +129,7 @@ "type": "object" }, "forward_client_headers": { - "description": "Inbound client headers forwarded to the upstream, as single-`*` glob patterns matched case-insensitively against the header name (`\"anthropic-beta\"`, `\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing, which is the behavior of every standard-protocol endpoint before AISIX-Cloud#1167.\n\nA header named here reaches the upstream whatever the gateway would otherwise do with it. Naming a credential slot — `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` — hands the upstream the caller's own credential in place of the one this ProviderKey would inject there, never both. That is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged. Any OTHER header the gateway had already set is left alone: it selects how the exchange works, not who it is from.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nTwo cases where a named header still does not reach the upstream. A `default_headers` entry of the same name wins it for every name except a credential slot: both are operator configuration and the static one is the more specific choice, but in a credential slot the forwarded value is precisely the one that was asked for, so it takes the slot from the static entry. And on an AWS Bedrock provider the request signer owns `authorization`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token`, `x-amz-target` and `x-amzn-bedrock-accept`, and drops any supplied value — a value there would not authenticate anyone: it either loses to the signer or breaks the signature.\n\nNaming a credential slot needs a data plane new enough to honor it; an older one refuses those names outright, so the pattern has no effect there rather than a different one.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace. The headers describing a body this gateway re-serializes or a response shape it parses (`content-type`, `content-length`, `accept`, `anthropic-version`, `x-stainless-*`) are excluded for the same reason. `traceparent` and `tracestate` are forwarded only when a pattern names them exactly — a glob is not read as consent to graft the caller's trace onto the upstream's telemetry.", + "description": "Inbound client headers forwarded to the upstream, as single-`*` glob patterns matched case-insensitively against the header name (`\"anthropic-beta\"`, `\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing, which is the behavior of every standard-protocol endpoint before AISIX-Cloud#1167.\n\nA header the caller sends more than once is forwarded with its first value only; the upstream receives one well-formed header rather than a list this gateway never interpreted. An HTTP/2 caller may split `cookie` across several header fields, and only the first of them is forwarded.\n\nA header named here reaches the upstream whatever the gateway would otherwise do with it. Naming a credential slot — `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` — hands the upstream the caller's own credential in place of the one this ProviderKey would inject there, never both. That is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged. Any OTHER header the gateway had already set is left alone: it selects how the exchange works, not who it is from.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nTwo cases where a named header still does not reach the upstream. A `default_headers` entry of the same name wins it for every name except a credential slot: both are operator configuration and the static one is the more specific choice, but in a credential slot the forwarded value is precisely the one that was asked for, so it takes the slot from the static entry. And on an AWS Bedrock provider the request signer owns `authorization`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token`, `x-amz-target` and `x-amzn-bedrock-accept`, and drops any supplied value — a value there would not authenticate anyone: it either loses to the signer or breaks the signature.\n\nNaming a credential slot needs a data plane new enough to honor it; an older one refuses those names outright, so the pattern has no effect there rather than a different one.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace. The headers describing a body this gateway re-serializes or a response shape it parses (`content-type`, `content-length`, `accept`, `anthropic-version`, `x-stainless-*`) are excluded for the same reason. `traceparent` and `tracestate` are forwarded only when a pattern names them exactly — a glob is not read as consent to graft the caller's trace onto the upstream's telemetry.", "items": { "type": "string" }, From b4a031f3e22abe2a6fb33e3d748d6bbe720d3fd0 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 7 Sep 2026 04:04:11 +0000 Subject: [PATCH 2/3] test: pin the multi-value rules the descriptions now promise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit turned a code comment into a public contract on two planes, with nothing keeping it true. Every fixture in the `forwarded_headers` tests is built with `HeaderMap::insert` via `map()`, which cannot express a header the caller sent twice, so no test went red if `resolve_forwarded_client_headers` stopped collapsing to the first value — and none covered the passthrough path's opposite guarantee. Two tests, one per rule, each verified to fail without the behavior it pins: switching the resolver to per-value iteration turns the first into `["cookie", "cookie"]`, and switching the passthrough loop to `keys()` + `get()` drops `second` from the second. The passthrough case deliberately repeats a header the ProviderKey strips, so it covers the strip-override branch rather than the plain default-forward one. Also narrows the injection comment above the credential inject: "the wire stays single-valued" was true of what that block does and is now easy to read as a claim about the whole request, which the passthrough description explicitly contradicts. --- crates/aisix-core/src/forwarded_headers.rs | 20 ++++++++++ crates/aisix-proxy/src/passthrough_route.rs | 42 ++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/crates/aisix-core/src/forwarded_headers.rs b/crates/aisix-core/src/forwarded_headers.rs index 2eb1aac6..928350cc 100644 --- a/crates/aisix-core/src/forwarded_headers.rs +++ b/crates/aisix-core/src/forwarded_headers.rs @@ -507,6 +507,26 @@ mod tests { assert_eq!(got, vec!["authorization", "x-trace-id"]); } + /// The `provider_key`, `mcp_server` and `a2a_agent` field + /// descriptions promise this to users, so it is pinned here rather + /// than left to the `keys()` + `get()` pair that happens to produce + /// it. `append` is the whole point: `map()` above builds with + /// `insert`, which cannot express a header the caller sent twice, so + /// no other test in this module can go red if the rule changes. + #[test] + fn a_repeated_header_forwards_its_first_value_only() { + let mut client = HeaderMap::new(); + for v in ["a=1", "b=2"] { + client.append( + HeaderName::from_static("cookie"), + HeaderValue::from_str(v).unwrap(), + ); + } + let got = resolve_forwarded_client_headers(&["cookie".into()], &client, &[]); + assert_eq!(names(&got), vec!["cookie"]); + assert_eq!(got[0].1.to_str().unwrap(), "a=1"); + } + #[test] fn forwarded_values_are_marked_sensitive() { let client = map(&[("authorization", "Bearer caller")]); diff --git a/crates/aisix-proxy/src/passthrough_route.rs b/crates/aisix-proxy/src/passthrough_route.rs index 8bb14e9b..99af4804 100644 --- a/crates/aisix-proxy/src/passthrough_route.rs +++ b/crates/aisix-proxy/src/passthrough_route.rs @@ -776,7 +776,11 @@ async fn dispatch( } // Inject the gateway-held upstream credential (inject mode only). - // Strip ran first, so the wire stays single-valued (#411 ordering). + // Strip ran first, so this never adds a second value to a slot the + // caller's own header already took (#411 ordering). That is a + // statement about the INJECTION, not about the wire: a caller who + // repeated the slot still has every value relayed below, which is + // what `forward_client_headers` promises on this surface. if let Some(pk) = pk_entry.as_ref() { let api_key = pk.value.api_key.as_str(); let provider_lower = pk.value.provider.to_ascii_lowercase(); @@ -3636,6 +3640,42 @@ mod tests { assert_eq!(auths[0], "Bearer sk-upstream"); } + /// `passthrough_route` is the one surface that relays EVERY value of + /// a repeated header — the other three collapse to the first — and + /// its field description now promises that to users. The only thing + /// keeping the promise is that this path walks the inbound map per + /// value instead of per name, so collapsing it must go red here. + #[tokio::test] + async fn a_repeated_header_forwards_every_value() { + let (upstream, snap) = slot_route_fixture(serde_json::json!({ + "forward_client_headers": ["x-*"] + })) + .await; + + // `x-stripped-control` is in the ProviderKey's strip set and + // [`slot_request`] always sends one, so the second copy makes + // this the STRIP-OVERRIDE path rather than the default-forward + // one — the branch where a per-name decision would be easiest to + // write and would silently drop a value. + let resp = build_app(snap) + .oneshot(slot_request(&[ + ("authorization", "Bearer sk-caller"), + ("x-stripped-control", "second"), + ])) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let received = &upstream.received_requests().await.unwrap()[0]; + let got: Vec<_> = received + .headers + .get_all("x-stripped-control") + .iter() + .map(|v| v.to_str().unwrap()) + .collect(); + assert_eq!(got, vec!["recovered", "second"]); + } + /// An `inject` route with the given overrides merged onto it. The /// upstream always answers `/v1/models`, and every request through /// [`slot_request`] carries an ordinary `x-other`, so each test above From 816568de599f76d5b5976259450ae645cf79816a Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 7 Sep 2026 04:15:28 +0000 Subject: [PATCH 3/3] chore(schema): regenerate the lenient schemas for the new descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1137 landed `schemas/resources-lenient/` on main after this branch was cut. Those files are generated from the same doc comments this branch edits, so the merge left them carrying the pre-change descriptions — which the drift gate and the two characterization tests that compare the published set against what the loader compiles both catch. Regenerated with `cargo run -p aisix-core --bin dump-schema`. The four changed lines are the same four descriptions, in the lenient copies. --- schemas/resources-lenient/a2a_agent.schema.json | 2 +- schemas/resources-lenient/mcp_server.schema.json | 2 +- schemas/resources-lenient/passthrough_route.schema.json | 2 +- schemas/resources-lenient/provider_key.schema.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/schemas/resources-lenient/a2a_agent.schema.json b/schemas/resources-lenient/a2a_agent.schema.json index 6e4e32f0..dc13ee70 100644 --- a/schemas/resources-lenient/a2a_agent.schema.json +++ b/schemas/resources-lenient/a2a_agent.schema.json @@ -136,7 +136,7 @@ "type": "boolean" }, "forward_client_headers": { - "description": "Inbound client headers forwarded to this agent, as single-`*` glob patterns matched case-insensitively against the header name (`\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing. Applies to the agent-card fetch at `/a2a//.well-known/agent-card.json` as well as to every JSON-RPC method served at `/a2a/`, so an agent receives them on `message/send`, `message/stream` and every task operation alike.\n\nA header named here reaches the agent whatever the gateway would otherwise do with it. Naming the credential slot `auth_type` would fill — `authorization` for `bearer`, `x-api-key` for `api_key` — hands the agent the caller's own credential in place of the gateway's, never both. That is what lets an internal agent that already authorizes on the end user's `Authorization` keep doing so unchanged. An agent that validates the `aud` claim will reject a token minted for the gateway.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, the gateway's `x-aisix-*` namespace, the headers describing a body this gateway re-serializes (`content-type`, `content-length`, `accept`), and `a2a-version`, which is the gateway's own announcement of the wire version pinned in `protocol_version` and which a caller's value would override.", + "description": "Inbound client headers forwarded to this agent, as single-`*` glob patterns matched case-insensitively against the header name (`\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing. Applies to the agent-card fetch at `/a2a//.well-known/agent-card.json` as well as to every JSON-RPC method served at `/a2a/`, so an agent receives them on `message/send`, `message/stream` and every task operation alike.\n\nA header the caller sends more than once is forwarded with its first value only; the upstream receives one well-formed header rather than a list this gateway never interpreted. An HTTP/2 caller may split `cookie` across several header fields, and only the first of them is forwarded.\n\nA header named here reaches the agent whatever the gateway would otherwise do with it. Naming the credential slot `auth_type` would fill — `authorization` for `bearer`, `x-api-key` for `api_key` — hands the agent the caller's own credential in place of the gateway's, never both. That is what lets an internal agent that already authorizes on the end user's `Authorization` keep doing so unchanged. An agent that validates the `aud` claim will reject a token minted for the gateway.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, the gateway's `x-aisix-*` namespace, the headers describing a body this gateway re-serializes (`content-type`, `content-length`, `accept`), and `a2a-version`, which is the gateway's own announcement of the wire version pinned in `protocol_version` and which a caller's value would override.", "items": { "type": "string" }, diff --git a/schemas/resources-lenient/mcp_server.schema.json b/schemas/resources-lenient/mcp_server.schema.json index 8fb43fd0..e2e3685f 100644 --- a/schemas/resources-lenient/mcp_server.schema.json +++ b/schemas/resources-lenient/mcp_server.schema.json @@ -306,7 +306,7 @@ "type": "boolean" }, "forward_client_headers": { - "description": "Inbound client headers forwarded to this server, as single-`*` glob patterns matched case-insensitively against the header name (`\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing. Applies to both `type: mcp` and `type: openapi`, so a REST API exposed here as tools receives them on every tool call.\n\nA header named here reaches the server whatever the gateway would otherwise do with it. Naming the credential slot `auth_type` would fill — `authorization` for `bearer` and `oauth2`, `api_key_header` for `api_key` — hands the server the caller's own credential in place of the gateway's, never both. That is what lets an internal server that already authorizes on the end user's `Authorization` keep doing so unchanged. A server that validates the `aud` claim will reject a token minted for the gateway.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, the gateway's `x-aisix-*` namespace, the headers describing a body this gateway re-serializes (`content-type`, `content-length`, `accept`), and the MCP session slots (`mcp-session-id`, `mcp-protocol-version`, `last-event-id`), which name the caller's session with this gateway and which an upstream MCP server refuses outright when they carry a foreign value.", + "description": "Inbound client headers forwarded to this server, as single-`*` glob patterns matched case-insensitively against the header name (`\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing. Applies to both `type: mcp` and `type: openapi`, so a REST API exposed here as tools receives them on every tool call.\n\nA header the caller sends more than once is forwarded with its first value only; the upstream receives one well-formed header rather than a list this gateway never interpreted. An HTTP/2 caller may split `cookie` across several header fields, and only the first of them is forwarded.\n\nA header named here reaches the server whatever the gateway would otherwise do with it. Naming the credential slot `auth_type` would fill — `authorization` for `bearer` and `oauth2`, `api_key_header` for `api_key` — hands the server the caller's own credential in place of the gateway's, never both. That is what lets an internal server that already authorizes on the end user's `Authorization` keep doing so unchanged. A server that validates the `aud` claim will reject a token minted for the gateway.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, the gateway's `x-aisix-*` namespace, the headers describing a body this gateway re-serializes (`content-type`, `content-length`, `accept`), and the MCP session slots (`mcp-session-id`, `mcp-protocol-version`, `last-event-id`), which name the caller's session with this gateway and which an upstream MCP server refuses outright when they carry a foreign value.", "items": { "type": "string" }, diff --git a/schemas/resources-lenient/passthrough_route.schema.json b/schemas/resources-lenient/passthrough_route.schema.json index 0e17a78e..a4fe43c8 100644 --- a/schemas/resources-lenient/passthrough_route.schema.json +++ b/schemas/resources-lenient/passthrough_route.schema.json @@ -484,7 +484,7 @@ "type": "boolean" }, "forward_client_headers": { - "description": "Inbound client headers forwarded to the upstream even when this route would otherwise strip them, as single-`*` glob patterns matched case-insensitively against the header name (`\"authorization\"`, `\"x-trace-*\"`). Empty — the default — overrides no stripping.\n\nA route forwards the caller's headers by default, so this field only matters for the ones it removes: the ProviderKey's `strip_headers` under `credential_mode: inject`, and the slot the gateway consumed to authenticate the caller. Naming `authorization` under `auth_mode: gateway_key` therefore puts the caller's own credential back on the upstream request in place of the one this route would inject, never both — which is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged.\n\nA credential slot — `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` — and `traceparent` / `tracestate` are forwarded only when a pattern names them exactly. A glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry, so a broad pattern overrides the rest of the strip set and leaves those alone.\n\nThis route's own `auth_header_name` and `identity_header` are read the same way. Both are slots this route chose rather than ones the gateway owns — under `auth_mode: header_key` the first carries the gateway credential the caller authenticated with, and the second carries an end-user identity this route records and strips — so a glob does not sweep either, and a pattern that names one in full forwards it.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are stripped whatever the patterns say: `host`, `content-length`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace.", + "description": "Inbound client headers forwarded to the upstream even when this route would otherwise strip them, as single-`*` glob patterns matched case-insensitively against the header name (`\"authorization\"`, `\"x-trace-*\"`). Empty — the default — overrides no stripping.\n\nA header the caller sends more than once is forwarded with every value preserved.\n\nA route forwards the caller's headers by default, so this field only matters for the ones it removes: the ProviderKey's `strip_headers` under `credential_mode: inject`, and the slot the gateway consumed to authenticate the caller. Naming `authorization` under `auth_mode: gateway_key` therefore puts the caller's own credential back on the upstream request in place of the one this route would inject, never both — which is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged.\n\nA credential slot — `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` — and `traceparent` / `tracestate` are forwarded only when a pattern names them exactly. A glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry, so a broad pattern overrides the rest of the strip set and leaves those alone.\n\nThis route's own `auth_header_name` and `identity_header` are read the same way. Both are slots this route chose rather than ones the gateway owns — under `auth_mode: header_key` the first carries the gateway credential the caller authenticated with, and the second carries an end-user identity this route records and strips — so a glob does not sweep either, and a pattern that names one in full forwards it.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are stripped whatever the patterns say: `host`, `content-length`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace.", "items": { "type": "string" }, diff --git a/schemas/resources-lenient/provider_key.schema.json b/schemas/resources-lenient/provider_key.schema.json index 573d7fc3..b19ea24b 100644 --- a/schemas/resources-lenient/provider_key.schema.json +++ b/schemas/resources-lenient/provider_key.schema.json @@ -123,7 +123,7 @@ "type": "object" }, "forward_client_headers": { - "description": "Inbound client headers forwarded to the upstream, as single-`*` glob patterns matched case-insensitively against the header name (`\"anthropic-beta\"`, `\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing, which is the behavior of every standard-protocol endpoint before AISIX-Cloud#1167.\n\nA header named here reaches the upstream whatever the gateway would otherwise do with it. Naming a credential slot — `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` — hands the upstream the caller's own credential in place of the one this ProviderKey would inject there, never both. That is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged. Any OTHER header the gateway had already set is left alone: it selects how the exchange works, not who it is from.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nTwo cases where a named header still does not reach the upstream. A `default_headers` entry of the same name wins it for every name except a credential slot: both are operator configuration and the static one is the more specific choice, but in a credential slot the forwarded value is precisely the one that was asked for, so it takes the slot from the static entry. And on an AWS Bedrock provider the request signer owns `authorization`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token`, `x-amz-target` and `x-amzn-bedrock-accept`, and drops any supplied value — a value there would not authenticate anyone: it either loses to the signer or breaks the signature.\n\nNaming a credential slot needs a data plane new enough to honor it; an older one refuses those names outright, so the pattern has no effect there rather than a different one.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace. The headers describing a body this gateway re-serializes or a response shape it parses (`content-type`, `content-length`, `accept`, `anthropic-version`, `x-stainless-*`) are excluded for the same reason. `traceparent` and `tracestate` are forwarded only when a pattern names them exactly — a glob is not read as consent to graft the caller's trace onto the upstream's telemetry.", + "description": "Inbound client headers forwarded to the upstream, as single-`*` glob patterns matched case-insensitively against the header name (`\"anthropic-beta\"`, `\"x-trace-*\"`, `\"authorization\"`). Empty — the default — forwards nothing, which is the behavior of every standard-protocol endpoint before AISIX-Cloud#1167.\n\nA header the caller sends more than once is forwarded with its first value only; the upstream receives one well-formed header rather than a list this gateway never interpreted. An HTTP/2 caller may split `cookie` across several header fields, and only the first of them is forwarded.\n\nA header named here reaches the upstream whatever the gateway would otherwise do with it. Naming a credential slot — `authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`, and the AWS SigV4 trio `x-amz-security-token` / `x-amz-date` / `x-amz-content-sha256` — hands the upstream the caller's own credential in place of the one this ProviderKey would inject there, never both. That is what lets an internal service that already authorizes on the end user's `Authorization` keep doing so unchanged. Any OTHER header the gateway had already set is left alone: it selects how the exchange works, not who it is from.\n\nA credential slot, and `traceparent` / `tracestate`, are forwarded only when a pattern names them exactly — a glob such as `\"*\"` or `\"x-*\"` is a statement about the operator's own headers, not consent to hand a third party the caller's credential or to graft the caller's trace onto that party's telemetry.\n\nTwo cases where a named header still does not reach the upstream. A `default_headers` entry of the same name wins it for every name except a credential slot: both are operator configuration and the static one is the more specific choice, but in a credential slot the forwarded value is precisely the one that was asked for, so it takes the slot from the static entry. And on an AWS Bedrock provider the request signer owns `authorization`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token`, `x-amz-target` and `x-amzn-bedrock-accept`, and drops any supplied value — a value there would not authenticate anyone: it either loses to the signer or breaks the signature.\n\nNaming a credential slot needs a data plane new enough to honor it; an older one refuses those names outright, so the pattern has no effect there rather than a different one.\n\nHeaders whose forwarding would break the exchange rather than change who it comes from are never forwarded whatever the patterns say: `host`, the hop-by-hop headers that describe the caller's own connection, and the gateway's `x-aisix-*` namespace. The headers describing a body this gateway re-serializes or a response shape it parses (`content-type`, `content-length`, `accept`, `anthropic-version`, `x-stainless-*`) are excluded for the same reason. `traceparent` and `tracestate` are forwarded only when a pattern names them exactly — a glob is not read as consent to graft the caller's trace onto the upstream's telemetry.", "items": { "type": "string" },