Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions crates/aisix-core/src/forwarded_headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]);
Expand Down
6 changes: 6 additions & 0 deletions crates/aisix-core/src/models/a2a_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ pub struct A2aAgent {
/// method served at `/a2a/<name>`, 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
Expand Down
6 changes: 6 additions & 0 deletions crates/aisix-core/src/models/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 3 additions & 0 deletions crates/aisix-core/src/models/passthrough_route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions crates/aisix-core/src/models/provider_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
42 changes: 41 additions & 1 deletion crates/aisix-proxy/src/passthrough_route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion schemas/resources-lenient/a2a_agent.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/.well-known/agent-card.json` as well as to every JSON-RPC method served at `/a2a/<name>`, 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/<name>/.well-known/agent-card.json` as well as to every JSON-RPC method served at `/a2a/<name>`, 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"
},
Expand Down
2 changes: 1 addition & 1 deletion schemas/resources-lenient/mcp_server.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion schemas/resources-lenient/passthrough_route.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading