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
89 changes: 88 additions & 1 deletion crates/aisix-core/src/forwarded_headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,36 @@ pub const TRACE_CONTEXT_HEADERS: &[&str] = &["traceparent", "tracestate"];
/// `forward_client_headers: ["x-*"]` does not start relaying the caller's
/// `x-api-key` — which on `/v1/*` is the caller's own gateway key — the
/// day it upgrades.
///
/// The list here is the one every surface shares, and it is complete only
/// where the caller's credential always arrives in a slot it names — on
/// `/v1/*` and MCP the gateway reads `authorization` or `x-api-key` and
/// nothing else. A surface where the OPERATOR chooses the slot has to add
/// its own; see [`exact_match_only_with`].
pub fn exact_match_only(name: &str) -> bool {
CREDENTIAL_SLOT_HEADERS.contains(&name) || TRACE_CONTEXT_HEADERS.contains(&name)
}

/// [`exact_match_only`] plus the slots one surface names for itself.
///
/// A `passthrough_route` picks its own header names: under `auth_mode:
/// header_key` the gateway credential arrives in the route's
/// `auth_header_name`, and `identity_header` carries an end-user identity
/// the route promises to record and strip. The route schema forbids MOST
/// of the shared credential names for those two fields, and the two it
/// does allow (`api-key`, `x-goog-api-key`) are on the shared list
/// already — so the union below is what makes the rule complete, and
/// without it a `["x-*"]` pattern sweeps in exactly the header the
/// gateway just consumed to authenticate
/// the caller and relays it upstream, where it can be replayed against
/// this gateway.
///
/// The rule is unchanged, only its input: a glob does not reach these
/// names, and a pattern that spells one out still forwards it.
pub fn exact_match_only_with(name: &str, surface_slots: &[&str]) -> bool {
exact_match_only(name) || surface_slots.iter().any(|s| s.eq_ignore_ascii_case(name))
}

/// Whether a forwarded value may DISPLACE a header the gateway already
/// placed on the outbound request.
///
Expand Down Expand Up @@ -177,7 +203,17 @@ pub fn client_header_forwardable(name: &str) -> bool {
/// snapshot. [`exact_match_only`] names the exception: those headers need
/// a pattern that spells them out.
pub fn forward_pattern_admits(patterns: &[String], name: &str) -> bool {
if exact_match_only(name) {
forward_pattern_admits_with(patterns, name, &[])
}

/// [`forward_pattern_admits`] for a surface that names credential slots of
/// its own — see [`exact_match_only_with`].
pub fn forward_pattern_admits_with(
patterns: &[String],
name: &str,
surface_slots: &[&str],
) -> bool {
if exact_match_only_with(name, surface_slots) {
return patterns.iter().any(|p| p.eq_ignore_ascii_case(name));
}
patterns
Expand Down Expand Up @@ -341,6 +377,57 @@ mod tests {
}
}

/// The shared list cannot know the name a `passthrough_route` picked
/// for its own gateway credential, and the route schema guarantees it
/// is NOT one of the shared names. So a glob would sweep in exactly
/// the header the gateway consumed to authenticate this caller.
#[test]
fn a_surface_slot_needs_its_own_name_not_a_glob() {
let slots = ["x-gw-key", "x-end-user"];
for name in slots {
assert!(!exact_match_only(name), "{name}");
assert!(exact_match_only_with(name, &slots), "{name}");
assert!(!forward_pattern_admits_with(&["*".into()], name, &slots));
assert!(!forward_pattern_admits_with(&["x-*".into()], name, &slots));
// Naming it in full is still consent, on this face as on
// every other.
assert!(forward_pattern_admits_with(
&[name.to_string()],
name,
&slots
));
assert!(forward_pattern_admits_with(
&[name.to_uppercase()],
name,
&slots
));
}
// Only the named slots move: an ordinary header on the same
// surface still answers to the glob.
assert!(forward_pattern_admits_with(
&["x-*".into()],
"x-trace-id",
&slots
));
// And a surface that names none behaves exactly as before —
// whether it passes an empty list, or (as the passthrough call
// site does, to keep a two-element list off the heap) a fixed
// array with `""` standing for an unset slot. A header name is
// never empty, on the wire or in the schema, so the sentinel
// matches nothing and needs no filtering out.
assert!(forward_pattern_admits_with(
&["x-*".into()],
"x-gw-key",
&[]
));
assert!(!exact_match_only_with("x-gw-key", &["", ""]));
assert!(forward_pattern_admits_with(
&["x-*".into()],
"x-gw-key",
&["", ""]
));
}

#[test]
fn an_empty_allowlist_forwards_nothing() {
let client = map(&[("authorization", "Bearer caller"), ("x-trace-id", "t")]);
Expand Down
8 changes: 4 additions & 4 deletions crates/aisix-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ pub use error::{
AdminError, AdminErrorEnvelope, BootstrapError, ProxyError, ProxyErrorEnvelope, RateLimitScope,
};
pub use forwarded_headers::{
client_header_forwardable, displaces_a_gateway_header, exact_match_only,
forward_pattern_admits, header_forward_blocked, resolve_forwarded_client_headers,
CREDENTIAL_SLOT_HEADERS, GATEWAY_HEADER_PREFIX, NEVER_FORWARD_FROM_CLIENT,
NEVER_FORWARD_FROM_CLIENT_PREFIXES, NON_FORWARDABLE_HEADERS,
client_header_forwardable, displaces_a_gateway_header, exact_match_only, exact_match_only_with,
forward_pattern_admits, forward_pattern_admits_with, header_forward_blocked,
resolve_forwarded_client_headers, CREDENTIAL_SLOT_HEADERS, GATEWAY_HEADER_PREFIX,
NEVER_FORWARD_FROM_CLIENT, NEVER_FORWARD_FROM_CLIENT_PREFIXES, NON_FORWARDABLE_HEADERS,
};
pub use header_template::{render_header_template, HeaderVars, HEADER_TEMPLATE_VARS};
pub use models::{
Expand Down
8 changes: 8 additions & 0 deletions crates/aisix-core/src/models/passthrough_route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ pub struct PassthroughRoute {
/// party's telemetry, so a broad pattern overrides the rest of the
/// strip set and leaves those alone.
///
/// This 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.
///
/// Headers 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
Expand Down
14 changes: 9 additions & 5 deletions crates/aisix-core/src/models/provider_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,11 +389,15 @@ pub struct RequestOverrides {
/// Two cases where a named header still does not reach the upstream:
/// a `default_headers` entry of the same name wins it, since both are
/// operator configuration and the static one is the more specific
/// choice; and on an AWS Bedrock provider the SigV4-signed names
/// (`authorization`, `x-amz-date`, `x-amz-content-sha256`,
/// `x-amz-security-token`) are refused from either source, because
/// the request signer derives them and a supplied value would break
/// the signature rather than authenticate anyone.
/// choice; 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.
///
/// Naming 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.
///
/// Headers whose forwarding would break the exchange rather than
/// change who it comes from are never forwarded whatever the patterns
Expand Down
3 changes: 3 additions & 0 deletions crates/aisix-gateway/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ impl BridgeContext {
provider_key_name: Some(&self.provider_key.display_name),
},
client_headers: self.client_headers.as_deref(),
// A Bridge speaks HTTP to its upstream and owns no header
// name the shared lists do not already cover.
surface_blocked: &[],
}
}
}
Expand Down
34 changes: 33 additions & 1 deletion crates/aisix-gateway/src/upstream_headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ pub struct UpstreamHeaderContext<'a> {
/// poll of an async job, a semantic-routing embedding lookup) — those
/// requests forward nothing.
pub client_headers: Option<&'a HeaderMap>,
/// Header names THIS surface owns, which the shared lists cannot know
/// about. `/v1/realtime` opens its own WebSocket, so the caller's
/// `sec-websocket-*` slots describe the connection the caller opened
/// and one of them carries the caller's own gateway key.
pub surface_blocked: &'a [&'a str],
}

impl<'a> UpstreamHeaderContext<'a> {
Expand All @@ -126,6 +131,13 @@ impl<'a> UpstreamHeaderContext<'a> {
self.client_headers = Some(headers);
self
}

/// Names this surface refuses on top of the shared lists — see
/// [`UpstreamHeaderContext::surface_blocked`].
pub fn with_surface_blocked(mut self, blocked: &'a [&'a str]) -> Self {
self.surface_blocked = blocked;
self
}
}

/// Resolve the operator-configured headers for one upstream call: rendered
Expand Down Expand Up @@ -221,7 +233,7 @@ impl ForwardedClientHeaders {
entries: aisix_core::resolve_forwarded_client_headers(
&r.forward_client_headers,
client,
&[],
ctx.surface_blocked,
),
}
}
Expand Down Expand Up @@ -250,6 +262,26 @@ impl ForwardedClientHeaders {
/// put two values on the wire and let the upstream pick between them,
/// which on a credential slot is the #411 shape.
///
/// Drop the entries whose value is not ASCII, returning how many went.
///
/// Only `/v1/realtime` calls this. Its WebSocket client writes the
/// upstream handshake as text and calls `HeaderValue::to_str` on every
/// header it was given, so a single obs-text byte (0x80-0xFF — legal
/// in a header value, and accepted on the way in) fails the whole
/// upstream connection rather than that one header. Every other face
/// hands the bytes to reqwest, which writes them out unexamined, so a
/// caller sending `x-user-name: José` is forwarded there and would
/// otherwise be unable to open a realtime session at all.
///
/// Dropping rather than failing matches how this pipeline treats an
/// entry it cannot turn into a header anywhere else: the exchange goes
/// ahead without it.
pub fn drop_non_ascii_values(&mut self) -> usize {
let before = self.entries.len();
self.entries.retain(|(_, v)| v.to_str().is_ok());
before - self.entries.len()
}

/// Every surface delivers through here, including the ones that
/// resolve once and reuse across several round-trips (jobs, videos) —
/// the precedence is a property of this type, not of each call site.
Expand Down
23 changes: 23 additions & 0 deletions crates/aisix-provider-bedrock/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,28 @@ mod tests {
// honest about Bedrock specifics, not just generic SigV4.
let reserved = reserved_sigv4_headers();
assert!(reserved.contains(&"x-amzn-bedrock-accept"));
assert!(reserved.contains(&"x-amz-target"));
}

/// The `request.forward_client_headers` description in
/// `cp-admin.yaml` and `schemas/resources/provider_key.schema.json`
/// SPELLS OUT this list — it is what a user reads to know which names
/// a Bedrock upstream will refuse. Prose is a second definition and
/// drifts silently: it named four of these for a release while the
/// code refused six. Adding or removing an entry here fails this
/// assertion, which is the reminder to re-word the description.
#[test]
fn the_reserved_list_is_the_one_the_public_description_spells_out() {
assert_eq!(
reserved_sigv4_headers(),
[
"authorization",
"x-amz-date",
"x-amz-content-sha256",
"x-amz-security-token",
"x-amz-target",
"x-amzn-bedrock-accept",
]
);
}
}
Loading