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-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/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 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" }, 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" },