Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
71d4e05
fix(passthrough): preserve route and stream boundaries
moonming Sep 30, 2026
7a29378
fix(passthrough): harden query and stream coverage
moonming Sep 30, 2026
268781a
fix(passthrough): harden matrix route boundaries
moonming Sep 30, 2026
331eb57
fix(passthrough): normalize conflicting query keys
moonming Sep 30, 2026
9aee6af
fix(passthrough): canonicalize form query keys
moonming Sep 30, 2026
96bdf1c
fix(ratelimit): renew stream concurrency leases
moonming Sep 30, 2026
8ed8d6b
fix(ratelimit): type lease refresh interval
moonming Sep 30, 2026
64fa1b1
fix(ratelimit): protect stream leases during upgrades
moonming Sep 30, 2026
84f3015
fix(passthrough): cover forwarded JSON guardrail fields
moonming Sep 30, 2026
d0f7024
fix(passthrough): scan deeply nested JSON values
moonming Sep 30, 2026
6060944
fix(passthrough): retain raw stream usage
moonming Sep 30, 2026
1960771
test(proxy): cover supplemental passthrough fields
moonming Sep 30, 2026
190a427
fix(guardrail): preserve source-safe passthrough scans
moonming Sep 30, 2026
aecf7ad
fix(guardrail): satisfy stream scan lint
moonming Sep 30, 2026
a64ce6b
fix(guardrail): keep Responses media opaque
moonming Sep 30, 2026
19b4bc5
fix(guardrail): keep malformed envelopes private
moonming Sep 30, 2026
95fa14f
fix(passthrough): preserve stream guardrail source boundaries
moonming Sep 30, 2026
7908693
fix: enforce passthrough stream hold limits
moonming Sep 30, 2026
924687e
fix: harden passthrough stream concurrency
moonming Oct 1, 2026
4c9f339
chore: sync passthrough route schemas
moonming Oct 1, 2026
6e1fdef
fix: harden passthrough stream boundaries
moonming Oct 1, 2026
78722e0
Merge remote-tracking branch 'origin/main' into fix/issues-1736-1737-…
moonming Oct 1, 2026
9ca594f
fix: harden passthrough guardrail boundaries
moonming Oct 1, 2026
8f445b4
fix: bound nested passthrough tool-result scanning
moonming Oct 1, 2026
0344c1d
test: join deep passthrough telemetry by request id
moonming Oct 1, 2026
8ccdccd
fix: bound raw tool-result scan allocation
moonming Oct 1, 2026
b5007f6
fix(passthrough): satisfy raw scan clippy
moonming Oct 1, 2026
49e8f7a
fix: fail closed for unevaluable passthrough scans
moonming Oct 1, 2026
01c7b44
fix: satisfy passthrough selector lint
moonming Oct 1, 2026
c5234d7
fix: harden passthrough guardrail boundaries
moonming Oct 1, 2026
d8a40cc
fix: harden passthrough guardrail boundaries
moonming Oct 1, 2026
bf348ac
test: complete passthrough guardrail fixtures
moonming Oct 1, 2026
2cfa5db
test: remove obsolete passthrough selector helper
moonming Oct 1, 2026
2b674e8
fix: harden passthrough stream boundaries
moonming Oct 1, 2026
3bc5ac2
fix: restore passthrough guardrail compilation
moonming Oct 1, 2026
0225a9a
fix: satisfy responses guardrail lint
moonming Oct 1, 2026
26f291d
fix: enforce passthrough stream lease boundaries
moonming Oct 1, 2026
3140277
fix: return optional response carrier values
moonming Oct 1, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions crates/aisix-core/src/models/passthrough_route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,11 @@ pub struct PassthroughRoute {
pub forward_client_headers: Vec<String>,

/// Maximum time, in milliseconds, for the upstream exchange. Bounds
/// the response-header phase and any non-SSE body read, but never a
/// healthy SSE relay (which ends with the upstream stream or the
/// client hanging up). When omitted, the gateway default request
/// timeout applies the same way.
/// the response-header phase and any non-SSE body read. For SSE it
/// bounds the wait for the first byte and every later no-byte gap, but
/// not the total duration of a healthy relay (which ends with the
/// upstream stream or the client hanging up). When omitted, the gateway
/// default request timeout applies the same way.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub timeout_ms: Option<u64>,
Expand Down
115 changes: 111 additions & 4 deletions crates/aisix-gateway/src/upstream_tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ pub fn reqwest_material() -> &'static ReqwestTlsMaterial {
static PK_CLIENTS: OnceLock<dashmap::DashMap<UpstreamConnection, reqwest::Client>> =
OnceLock::new();

/// The raw-relay counterpart to [`PK_CLIENTS`]. Reqwest's content decoders
/// are client settings, so a passthrough relay that promises provider bytes
/// needs a separate pool even when it has the same TLS/resolve override.
static RAW_PK_CLIENTS: OnceLock<dashmap::DashMap<UpstreamConnection, reqwest::Client>> =
OnceLock::new();

/// Clients built for Provider Key connection overrides so far.
pub fn provider_key_client_count() -> u64 {
PK_CLIENTS.get().map_or(0, |clients| clients.len() as u64)
Expand All @@ -226,6 +232,11 @@ thread_local! {
/// a build failure has been reported for this thread.
static WORKER_CLIENT: std::cell::OnceCell<Option<reqwest::Client>> =
const { std::cell::OnceCell::new() };

/// The worker-owned raw-relay pool. It has the same TLS and connection
/// settings as `WORKER_CLIENT`, but no automatic content decoding.
static RAW_WORKER_CLIENT: std::cell::OnceCell<Option<reqwest::Client>> =
const { std::cell::OnceCell::new() };
}

/// Declares the calling thread a proxy worker with its own runtime, so
Expand Down Expand Up @@ -270,6 +281,54 @@ fn worker_client() -> Option<reqwest::Client> {
})
}

/// Build a client that relays response bytes exactly as received. In
/// particular, reqwest otherwise transparently decodes `gzip`, `br`,
/// `deflate`, and `zstd`, while removing the matching response headers.
fn raw_client_builder() -> reqwest::ClientBuilder {
// This workspace enables only reqwest's `gzip` decoder (see the
// workspace dependency declaration). The remaining decoder features are
// not compiled in, so disabling gzip is sufficient to preserve every
// representation this binary could otherwise transform.
crate::upstream_http::client_builder()
.no_gzip()
// The passthrough target is validated before dispatch. Following an
// upstream Location would make a second, unchecked request and could
// carry the injected provider credential beyond that boundary.
.redirect(reqwest::redirect::Policy::none())
}

fn raw_client() -> &'static reqwest::Client {
static RAW_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
// The same builder's TLS material is validated at boot. Do not fall back
// to a bare client here: that could make a raw relay silently trust less
// than the deployment configured.
RAW_CLIENT.get_or_init(|| {
raw_client_builder()
.build()
.expect("configured raw passthrough HTTP client builds")
})
}

fn raw_worker_client() -> Option<reqwest::Client> {
if !IS_WORKER_THREAD.get() {
return None;
}
RAW_WORKER_CLIENT.with(|cell| {
cell.get_or_init(|| match raw_client_builder().build() {
Ok(client) => Some(client),
Err(e) => {
tracing::error!(
error = %e,
"per-worker raw passthrough upstream pool could not be built; this worker \
dispatches on the shared raw pool"
);
None
}
})
.clone()
})
}

/// The client to dispatch this Provider Key's request on.
///
/// Returns `shared` unchanged whenever the key sets no override, which
Expand All @@ -294,11 +353,33 @@ pub fn client_for_provider_key(
// the shared pool is used, as it always was.
return worker_client().unwrap_or_else(|| shared.clone());
};
let cache = PK_CLIENTS.get_or_init(dashmap::DashMap::new);
client_for_provider_key_override(shared, conn, &PK_CLIENTS, build_provider_key_client)
}

/// The byte-preserving client for a passthrough raw relay. It applies the
/// same deployment TLS, per-ProviderKey CA, certificate-verification, and
/// address-resolution rules as [`client_for_provider_key`], but opts out of
/// every reqwest content decoder so a relay may retain both encoded bytes and
/// `Content-Encoding` / `Content-Length` headers.
pub fn raw_client_for_provider_key(conn: Option<&UpstreamConnection>) -> reqwest::Client {
let shared = raw_client();
let Some(conn) = conn.filter(|c| !c.is_noop()) else {
return raw_worker_client().unwrap_or_else(|| shared.clone());
};
client_for_provider_key_override(shared, conn, &RAW_PK_CLIENTS, build_raw_provider_key_client)
}

fn client_for_provider_key_override(
shared: &reqwest::Client,
conn: &UpstreamConnection,
cache: &OnceLock<dashmap::DashMap<UpstreamConnection, reqwest::Client>>,
build: fn(&UpstreamConnection) -> Result<reqwest::Client, String>,
) -> reqwest::Client {
let cache = cache.get_or_init(dashmap::DashMap::new);
if let Some(existing) = cache.get(conn) {
return existing.clone();
}
match build_provider_key_client(conn) {
match build(conn) {
Ok(client) => cache.entry(conn.clone()).or_insert(client).clone(),
Err(e) if conn.resolve.is_empty() => {
tracing::error!(
Expand Down Expand Up @@ -333,19 +414,29 @@ pub fn client_for_provider_key(
// `resolve_to_addrs` cannot fail, so the only way this second
// build fails is a TLS backend that would not initialise —
// which `shared` could not have been built over either.
build_provider_key_client(&resolution_only)
build(&resolution_only)
.map(|client| cache.entry(resolution_only).or_insert(client).clone())
.unwrap_or_else(|_| shared.clone())
}
}
}

fn build_provider_key_client(conn: &UpstreamConnection) -> Result<reqwest::Client, String> {
build_provider_key_client_with(conn, crate::upstream_http::client_builder())
}

fn build_raw_provider_key_client(conn: &UpstreamConnection) -> Result<reqwest::Client, String> {
build_provider_key_client_with(conn, raw_client_builder())
}

fn build_provider_key_client_with(
conn: &UpstreamConnection,
mut builder: reqwest::ClientBuilder,
) -> Result<reqwest::Client, String> {
// Layer the key's override ON TOP of the deployment settings rather
// than replacing them: a deployment CA and a per-key CA are both
// trust roots, and a client presenting the deployment's mTLS
// identity must keep presenting it.
let mut builder = crate::upstream_http::client_builder();
if let Some(tls) = conn.tls.as_ref() {
if let Some(pem) = tls.ca_cert.as_ref().filter(|p| !p.trim().is_empty()) {
let roots = reqwest::Certificate::from_pem_bundle(pem.as_bytes())
Expand Down Expand Up @@ -804,6 +895,12 @@ mod tests {
.is_some_and(|cache| cache.contains_key(conn))
}

fn raw_cached(conn: &UpstreamConnection) -> bool {
RAW_PK_CLIENTS
.get()
.is_some_and(|cache| cache.contains_key(conn))
}

/// A key carrying only `tls`, in the shape the dispatch sites derive.
fn tls_conn(tls: ProviderKeyTls) -> UpstreamConnection {
UpstreamConnection {
Expand Down Expand Up @@ -878,6 +975,16 @@ mod tests {
assert!(cached(&conn));
}

#[test]
fn raw_relay_keeps_provider_key_connection_overrides() {
let conn = resolve_conn("vendor-raw-relay.invalid", &["192.0.2.12"]);
let _ = raw_client_for_provider_key(Some(&conn));
assert!(
raw_cached(&conn),
"the raw relay must not discard a ProviderKey's address override"
);
}

/// Two keys pointing the same hostname at different addresses must not
/// share a client: the resolution lives on the client, so one pool
/// could only ever dial one of the two.
Expand Down
3 changes: 2 additions & 1 deletion crates/aisix-proxy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ http-body-util.workspace = true
bytes.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
# Token-estimation fallback for usage telemetry (token_estimate.rs).
tiktoken-rs.workspace = true
futures.workspace = true
Expand All @@ -61,6 +61,7 @@ base64.workspace = true
# Scheme validation of provider-supplied redirect targets on
# `GET /v1/videos/:id/content` (already in the tree via reqwest).
url.workspace = true
percent-encoding.workspace = true
# Per-request weighted-random target selection in `routing::weighted_pick`.
# `thread_rng()` gives proper per-request entropy that converges to the
# configured weights over a finite sample (fix for #197 — the prior
Expand Down
Loading
Loading