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
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.

51 changes: 38 additions & 13 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -531,22 +531,17 @@ upstream:
# gateway downloads it and sends the bytes inline. An upstream that
# reads the URL itself still gets the URL.
#
# The download only reaches publicly routable addresses: loopback,
# private (RFC 1918), CGNAT, link-local (including the 169.254.169.254
# metadata endpoint), IPv6 unique-local / link-local, multicast and
# reserved ranges are refused with a 400, for every address a name
# resolves to and for every redirect hop (at most 10). It follows the
# request's own upstream timeout, retries a timeout or connection
# failure up to 3 attempts in all, and never forwards the caller's
# headers.
# The URL is the caller's, so which addresses the download may reach is
# outbound.ssrf_protection (below). Otherwise it is an ordinary upstream
# request: it goes through HTTPS_PROXY / HTTP_PROXY / ALL_PROXY unless
# NO_PROXY covers the host, and verifies certificates per upstream.tls
# (ca_file, verify) — but never presents the upstream.tls client
# certificate. It follows at most 10 redirects, the request's own
# upstream timeout, retries a timeout or connection failure up to 3
# attempts in all, and never forwards the caller's headers.
# media_fetch:
# # false refuses such parts with a 400 instead of downloading them.
# enabled: true
# # Hosts that may be reached although they resolve to a non-public
# # address, as host (any port) or host:port; IPv6 with a port as
# # [addr]:port. There is no switch that turns the address check off.
# allowed_hosts: []
# # e.g. ["media.internal", "10.0.0.5:9000"]
# # Largest download accepted per URL, in bytes (default 50 MiB).
# # All downloads of one request also count, with its inbound body,
# # against proxy.request_body_limit_bytes when that is set (413 past it).
Expand All @@ -562,6 +557,36 @@ upstream:
# # GovCloud. One value serves every Bedrock provider key.
# mantle_endpoint: null

# Outbound requests to URLs an API caller supplied — today, the media
# download (upstream.media_fetch). URLs an operator configured (a provider
# key's api_base, guardrail, MCP and A2A endpoints, JWKS, exporters) are
# not affected.
# outbound:
# # Server-side request forgery protection. On (the default), such a
# # request only reaches publicly routable addresses: loopback, private
# # (RFC 1918), CGNAT, link-local (including the 169.254.169.254 metadata
# # endpoint), benchmarking (198.18.0.0/15), IPv6 unique-local /
# # link-local, multicast and reserved ranges are refused with a 400, for
# # every address a name resolves to and for every redirect hop. A direct
# # connection goes only to the addresses that were checked. When a
# # proxy applies to the URL, the name is resolved and checked locally
# # first and the proxy then resolves it again itself.
# #
# # Behind a fake-ip DNS or a transparent proxy (Clash, sing-box,
# # Surge, …) public names resolve into 198.18.0.0/15, so every
# # download is refused: list those hosts in allowed_hosts, or turn the
# # check off.
# ssrf_protection:
# # false turns the address check off entirely — no address is
# # refused and allowed_hosts is ignored.
# enabled: true
# # Hosts that may be reached although they resolve to a non-public
# # address, as host (any port) or host:port; IPv6 with a port as
# # [addr]:port. Env: AISIX_OUTBOUND__SSRF_PROTECTION__ALLOWED_HOSTS
# # takes a comma-separated list.
# allowed_hosts: []
# # e.g. ["media.internal", "10.0.0.5:9000"]

# Connection-layer settings for the inbound side — the clients, or the
# gateway in front of this one, talking to the listeners above. The
# mirror image of `upstream:`.
Expand Down
21 changes: 15 additions & 6 deletions config.managed.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -281,21 +281,30 @@ upstream:
# # client_cert_file / client_key_file for mutual TLS
# # verify: true # false accepts ANY certificate — test use only
# Media URLs an upstream cannot read itself (Bedrock, Claude on Vertex,
# http:// toward Anthropic / Gemini) are downloaded and sent inline.
# Only publicly routable addresses are reached; allowed_hosts (host or
# host:port) is the only exception. A request's downloads count, with
# its inbound body, against proxy.request_body_limit_bytes when set.
# See config.example.yaml.
# http:// toward Anthropic / Gemini) are downloaded and sent inline,
# through HTTP(S)_PROXY like any upstream call and verified per
# upstream.tls. Which addresses it may reach is outbound.ssrf_protection.
# A request's downloads count, with its inbound body, against
# proxy.request_body_limit_bytes when set. See config.example.yaml.
# media_fetch:
# enabled: true
# allowed_hosts: []
# max_bytes: 52428800
# Bedrock Mantle, where count_tokens for a Claude model bedrock-runtime
# cannot count is retried. null derives https://bedrock-mantle.{region}.api.aws
# from the provider key's region. See config.example.yaml.
# bedrock:
# mantle_endpoint: null

# URLs an API caller supplied (the media download) only reach publicly
# routable addresses; allowed_hosts (host or host:port) is the exception
# and enabled: false turns the check off. Behind a fake-ip DNS that maps
# public names into 198.18.0.0/15, list those hosts or turn it off.
# See config.example.yaml.
# outbound:
# ssrf_protection:
# enabled: true
# allowed_hosts: []

# Connection-layer settings for the inbound side. `idle_timeout_secs: 0`
# (the default) never closes an idle client connection, leaving that to
# the peer — set it only when a node in front pools connections for a
Expand Down
7 changes: 6 additions & 1 deletion config.reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@
},
"service_name": "aisix"
},
"outbound": {
"ssrf_protection": {
"allowed_hosts": [],
"enabled": true
}
},
"proxy": {
"addr": "0.0.0.0:3000",
"listeners": [],
Expand Down Expand Up @@ -111,7 +117,6 @@
},
"connect_timeout_ms": 5000,
"media_fetch": {
"allowed_hosts": [],
"enabled": true,
"max_bytes": 52428800
},
Expand Down
69 changes: 57 additions & 12 deletions crates/aisix-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ pub struct Config {
/// see [`UpstreamConfig`].
#[serde(default)]
pub upstream: UpstreamConfig,
/// Rules for outbound requests to URLs an API caller supplied — see
/// [`OutboundConfig`].
#[serde(default)]
pub outbound: OutboundConfig,
/// Connection-layer tuning for the inbound side: how long an idle
/// client connection is held, and how often a stalled SSE response
/// emits a heartbeat — see [`DownstreamConfig`].
Expand Down Expand Up @@ -1869,22 +1873,15 @@ pub struct BedrockUpstreamConfig {
/// downloads it and sends the bytes inline. An upstream that reads the URL
/// itself still gets the URL.
///
/// The download refuses any address that is not publicly routable
/// (loopback, private, link-local, cloud metadata, …); `allowed_hosts` is
/// the only way past that check.
/// Which addresses the download may reach is
/// [`OutboundConfig::ssrf_protection`].
Comment thread
coderabbitai[bot] marked this conversation as resolved.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields, default)]
pub struct MediaFetchConfig {
/// `false` refuses a media URL the target upstream cannot read with a
/// 400 instead of downloading it.
pub enabled: bool,
/// Hosts the download may reach even though they resolve to a
/// non-public address, as `host` (any port) or `host:port`; an IPv6
/// address is written in brackets when it carries a port
/// (`[fd00::1]:8080`). The host is still resolved once and the
/// connection pinned to that answer.
pub allowed_hosts: Vec<String>,
/// Largest body accepted per URL, in bytes. A larger download is
/// abandoned and the request refused with a 400.
pub max_bytes: u64,
Expand All @@ -1894,12 +1891,58 @@ impl Default for MediaFetchConfig {
fn default() -> Self {
Self {
enabled: true,
allowed_hosts: Vec::new(),
max_bytes: 50 * 1024 * 1024,
}
}
}

/// Outbound requests to a URL an API caller supplied, as opposed to one an
/// operator configured. Today that is the media download
/// (`upstream.media_fetch`). Configured URLs — a provider key's
/// `api_base`, guardrail, MCP and A2A endpoints, JWKS, exporters — are not
/// governed by it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields, default)]
pub struct OutboundConfig {
/// Server-side request forgery protection — see
/// [`SsrfProtectionConfig`].
pub ssrf_protection: SsrfProtectionConfig,
}

/// Keeps a caller-supplied URL from reaching the operator's own network.
///
/// On, every address such a request connects to must be publicly routable
/// (not loopback, private, link-local, CGNAT, cloud metadata, benchmarking
/// `198.18.0.0/15`, …). A host name is resolved and every address in the
/// answer checked; a direct connection goes only to those addresses, and
/// each redirect hop is checked the same way. When an `HTTP(S)_PROXY`
/// applies to the URL, the name is resolved and checked the same way
/// before the request is handed to the proxy, which then resolves it
/// again itself.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields, default)]
pub struct SsrfProtectionConfig {
/// `false` turns the address check off entirely: the request is an
/// ordinary fetch and `allowed_hosts` is ignored.
pub enabled: bool,
/// Hosts a request may reach even though they resolve to a
/// non-public address, as `host` (any port) or `host:port`; an IPv6
/// address is written in brackets when it carries a port
/// (`[fd00::1]:8080`).
pub allowed_hosts: Vec<String>,
}

impl Default for SsrfProtectionConfig {
fn default() -> Self {
Self {
enabled: true,
allowed_hosts: Vec::new(),
}
}
}

impl Default for UpstreamConfig {
fn default() -> Self {
Self {
Expand Down Expand Up @@ -2095,14 +2138,15 @@ impl Default for ShutdownConfig {
/// than handed to a root struct that rejects unknown fields.
/// `config_top_level_keys_match_the_struct` fails the build when a field
/// is added or renamed without this list following.
const TOP_LEVEL_ENV_KEYS: [&str; 12] = [
const TOP_LEVEL_ENV_KEYS: [&str; 13] = [
"admin",
"bedrock_endpoint_url",
"cache",
"downstream",
"etcd",
"managed",
"observability",
"outbound",
"proxy",
"ratelimit",
"resources_file",
Expand Down Expand Up @@ -2231,7 +2275,7 @@ const ENV_LIST_PARSE_KEYS: &[&str] = &[
"cache.redis.sentinels",
"ratelimit.redis.nodes",
"ratelimit.redis.sentinels",
"upstream.media_fetch.allowed_hosts",
"outbound.ssrf_protection.allowed_hosts",
];

impl Config {
Expand Down Expand Up @@ -5170,6 +5214,7 @@ observability:
"cache": {},
"ratelimit": {},
"upstream": {"tls": {}},
"outbound": {},
"downstream": {},
"shutdown": {},
"managed": {},
Expand Down
3 changes: 3 additions & 0 deletions crates/aisix-gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ dashmap.workspace = true
tracing.workspace = true
base64.workspace = true
url.workspace = true
# The environment-proxy matcher reqwest itself uses, so `ssrf` knows which
# caller-supplied URLs a proxy will take.
hyper-util = { version = "0.1", default-features = false, features = ["client-proxy"] }
# Outbound TLS trust (upstream_tls): the non-reqwest outbound stacks need
# the root store assembled directly, so the rustls side is a direct dep.
rustls.workspace = true
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-gateway/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub mod hub;
pub mod media_fetch;
pub mod response_tap;
pub mod sse;
pub mod ssrf;
pub mod structured_output;
pub mod tool_calls;
pub mod upstream_headers;
Expand Down
Loading
Loading