mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
802 lines
27 KiB
Rust
802 lines
27 KiB
Rust
use crate::admin_api::AdminAppState;
|
|
use crate::{AppState, GatewayError};
|
|
use aether_contracts::{
|
|
ExecutionPlan, ExecutionResult, ExecutionTimeouts, ProxySnapshot, RequestBody,
|
|
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER,
|
|
};
|
|
use aether_oauth::core::OAuthError;
|
|
use aether_oauth::network::{
|
|
OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse, OAuthNetworkPolicy, OAuthTimeouts,
|
|
};
|
|
use async_trait::async_trait;
|
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
|
use flate2::read::{DeflateDecoder, GzDecoder};
|
|
use futures_util::StreamExt;
|
|
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_ENCODING, CONTENT_TYPE};
|
|
use std::collections::BTreeMap;
|
|
use std::io::Read;
|
|
use std::net::{IpAddr, SocketAddr};
|
|
use std::time::Duration;
|
|
|
|
const OAUTH_RESPONSE_BODY_LIMIT_BYTES: usize = 4 * 1024 * 1024;
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct GatewayOAuthHttpExecutor<'a> {
|
|
app: AppState,
|
|
_marker: std::marker::PhantomData<&'a AppState>,
|
|
}
|
|
|
|
impl<'a> GatewayOAuthHttpExecutor<'a> {
|
|
pub(crate) fn new(state: AdminAppState<'a>) -> Self {
|
|
Self {
|
|
app: state.cloned_app(),
|
|
_marker: std::marker::PhantomData,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn from_app(app: &'a AppState) -> Self {
|
|
Self {
|
|
app: app.clone(),
|
|
_marker: std::marker::PhantomData,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl<'a> OAuthHttpExecutor for GatewayOAuthHttpExecutor<'a> {
|
|
async fn execute(&self, request: OAuthHttpRequest) -> Result<OAuthHttpResponse, OAuthError> {
|
|
match request.network.policy {
|
|
OAuthNetworkPolicy::DirectOnly | OAuthNetworkPolicy::DirectOrSystemProxy => {
|
|
match identity_oauth_route(request.network.policy, request.network.proxy.as_ref())?
|
|
{
|
|
IdentityOAuthRoute::Direct => {
|
|
execute_direct_identity_oauth(&self.app, request).await
|
|
}
|
|
}
|
|
}
|
|
OAuthNetworkPolicy::ProviderOperationProxy => {
|
|
#[cfg(test)]
|
|
if self.app.execution_runtime_override_base_url().is_none()
|
|
&& identity_oauth_endpoint_policy(&self.app, &request.url)
|
|
== IdentityOAuthEndpointPolicy::ExplicitTestLoopback
|
|
{
|
|
return execute_direct_identity_oauth(&self.app, request).await;
|
|
}
|
|
execute_provider_oauth_via_runtime(&self.app, request).await
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum IdentityOAuthRoute {
|
|
Direct,
|
|
}
|
|
|
|
fn identity_oauth_route(
|
|
policy: OAuthNetworkPolicy,
|
|
proxy: Option<&ProxySnapshot>,
|
|
) -> Result<IdentityOAuthRoute, OAuthError> {
|
|
let Some(proxy) = proxy.filter(|proxy| proxy.enabled != Some(false)) else {
|
|
return Ok(IdentityOAuthRoute::Direct);
|
|
};
|
|
|
|
if policy == OAuthNetworkPolicy::DirectOnly {
|
|
return Err(OAuthError::transport(
|
|
"identity OAuth direct-only transport cannot use a proxy",
|
|
));
|
|
}
|
|
|
|
let has_proxy_url = proxy
|
|
.url
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.is_some_and(|value| !value.is_empty());
|
|
if has_proxy_url {
|
|
return Err(OAuthError::transport(
|
|
"identity OAuth HTTP/SOCKS proxies are disabled; use a controlled tunnel or direct transport",
|
|
));
|
|
}
|
|
|
|
Err(OAuthError::transport(
|
|
"identity OAuth cannot use a configured proxy or tunnel; use direct transport",
|
|
))
|
|
}
|
|
|
|
async fn execute_provider_oauth_via_runtime(
|
|
app: &AppState,
|
|
request: OAuthHttpRequest,
|
|
) -> Result<OAuthHttpResponse, OAuthError> {
|
|
let plan = oauth_execution_plan(request, false);
|
|
let result = crate::execution_runtime::execute_execution_runtime_sync_plan(app, None, &plan)
|
|
.await
|
|
.map_err(gateway_error_to_oauth_error)?;
|
|
Ok(execution_result_to_oauth_response(&result))
|
|
}
|
|
|
|
fn oauth_execution_plan(request: OAuthHttpRequest, force_disable_redirects: bool) -> ExecutionPlan {
|
|
let OAuthHttpRequest {
|
|
request_id,
|
|
method,
|
|
url,
|
|
mut headers,
|
|
content_type,
|
|
json_body,
|
|
body_bytes,
|
|
network,
|
|
transport_profile,
|
|
} = request;
|
|
let body = if let Some(json_body) = json_body {
|
|
RequestBody::from_json(json_body)
|
|
} else {
|
|
RequestBody {
|
|
json_body: None,
|
|
body_bytes_b64: body_bytes.map(|bytes| STANDARD.encode(bytes)),
|
|
body_ref: None,
|
|
}
|
|
};
|
|
let timeouts = network.timeouts;
|
|
if force_disable_redirects {
|
|
headers.insert(
|
|
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER.to_string(),
|
|
"false".to_string(),
|
|
);
|
|
} else {
|
|
headers
|
|
.entry(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER.to_string())
|
|
.or_insert_with(|| "true".to_string());
|
|
}
|
|
let plan = ExecutionPlan {
|
|
request_id,
|
|
candidate_id: None,
|
|
provider_name: Some("oauth".to_string()),
|
|
provider_id: String::new(),
|
|
endpoint_id: String::new(),
|
|
key_id: String::new(),
|
|
method: method.as_str().to_string(),
|
|
url,
|
|
headers,
|
|
content_type,
|
|
content_encoding: None,
|
|
body,
|
|
stream: false,
|
|
client_api_format: "oauth:exchange".to_string(),
|
|
provider_api_format: "oauth:exchange".to_string(),
|
|
model_name: Some("oauth-exchange".to_string()),
|
|
proxy: network.proxy,
|
|
transport_profile,
|
|
timeouts: Some(ExecutionTimeouts {
|
|
connect_ms: Some(timeouts.connect_ms),
|
|
read_ms: Some(timeouts.read_ms),
|
|
write_ms: Some(timeouts.write_ms),
|
|
pool_ms: Some(timeouts.connect_ms),
|
|
total_ms: Some(timeouts.total_ms),
|
|
..ExecutionTimeouts::default()
|
|
}),
|
|
};
|
|
crate::execution_runtime::transport::with_upstream_response_body_limit(
|
|
&plan,
|
|
OAUTH_RESPONSE_BODY_LIMIT_BYTES,
|
|
)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ResolvedIdentityOAuthEndpoint {
|
|
url: reqwest::Url,
|
|
host: String,
|
|
addrs: Vec<SocketAddr>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum IdentityOAuthEndpointPolicy {
|
|
PublicHttps,
|
|
#[cfg(test)]
|
|
ExplicitTestLoopback,
|
|
}
|
|
|
|
fn identity_oauth_endpoint_policy(app: &AppState, raw_url: &str) -> IdentityOAuthEndpointPolicy {
|
|
#[cfg(test)]
|
|
{
|
|
let parsed_url = reqwest::Url::parse(raw_url).ok();
|
|
let is_explicit_test_url = app
|
|
.provider_oauth_token_url_overrides
|
|
.lock()
|
|
.expect("provider oauth token URL overrides should lock")
|
|
.values()
|
|
.any(|value| {
|
|
parsed_url
|
|
.as_ref()
|
|
.is_some_and(|url| test_loopback_override_allows_url(value, url))
|
|
});
|
|
if is_explicit_test_url {
|
|
return IdentityOAuthEndpointPolicy::ExplicitTestLoopback;
|
|
}
|
|
}
|
|
|
|
let _ = (app, raw_url);
|
|
IdentityOAuthEndpointPolicy::PublicHttps
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn test_loopback_override_allows_url(override_url: &str, target: &reqwest::Url) -> bool {
|
|
let Ok(registered) = reqwest::Url::parse(override_url) else {
|
|
return false;
|
|
};
|
|
let Some(target_ip) = target
|
|
.host_str()
|
|
.and_then(|host| host.parse::<IpAddr>().ok())
|
|
.filter(|address| address.is_loopback())
|
|
else {
|
|
return false;
|
|
};
|
|
let same_origin = registered.scheme() == target.scheme()
|
|
&& registered
|
|
.host_str()
|
|
.and_then(|host| host.parse::<IpAddr>().ok())
|
|
== Some(target_ip)
|
|
&& registered.port_or_known_default() == target.port_or_known_default();
|
|
if !same_origin || registered.query().is_some() || registered.fragment().is_some() {
|
|
return registered == *target;
|
|
}
|
|
|
|
let registered_path = registered.path().trim_end_matches('/');
|
|
registered == *target
|
|
|| registered_path.is_empty()
|
|
|| target
|
|
.path()
|
|
.strip_prefix(registered_path)
|
|
.is_some_and(|suffix| suffix.starts_with('/'))
|
|
}
|
|
|
|
fn parse_identity_oauth_endpoint(
|
|
raw_url: &str,
|
|
policy: IdentityOAuthEndpointPolicy,
|
|
) -> Result<(reqwest::Url, String, u16), OAuthError> {
|
|
let url = reqwest::Url::parse(raw_url)
|
|
.map_err(|_| OAuthError::transport("identity OAuth endpoint URL is invalid"))?;
|
|
if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() {
|
|
return Err(OAuthError::transport(
|
|
"identity OAuth endpoint must not contain credentials or a fragment",
|
|
));
|
|
}
|
|
let host = url
|
|
.host_str()
|
|
.map(ToOwned::to_owned)
|
|
.ok_or_else(|| OAuthError::transport("identity OAuth endpoint is missing a host"))?;
|
|
match policy {
|
|
IdentityOAuthEndpointPolicy::PublicHttps if url.scheme() != "https" => {
|
|
return Err(OAuthError::transport(
|
|
"identity OAuth endpoint must use HTTPS",
|
|
));
|
|
}
|
|
#[cfg(test)]
|
|
IdentityOAuthEndpointPolicy::ExplicitTestLoopback => {
|
|
let is_loopback_literal = host
|
|
.parse::<IpAddr>()
|
|
.is_ok_and(|address| address.is_loopback());
|
|
if !matches!(url.scheme(), "http" | "https") || !is_loopback_literal {
|
|
return Err(OAuthError::transport(
|
|
"test identity OAuth endpoint must use a loopback IP literal",
|
|
));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
let port = url
|
|
.port_or_known_default()
|
|
.ok_or_else(|| OAuthError::transport("identity OAuth endpoint is missing a port"))?;
|
|
Ok((url, host, port))
|
|
}
|
|
|
|
fn validate_identity_oauth_resolved_addrs(
|
|
addrs: &[SocketAddr],
|
|
policy: IdentityOAuthEndpointPolicy,
|
|
) -> Result<(), OAuthError> {
|
|
if addrs.is_empty() {
|
|
return Err(OAuthError::transport(
|
|
"identity OAuth endpoint DNS resolution returned no addresses",
|
|
));
|
|
}
|
|
#[cfg(test)]
|
|
if policy == IdentityOAuthEndpointPolicy::ExplicitTestLoopback {
|
|
if addrs.iter().all(|addr| addr.ip().is_loopback()) {
|
|
return Ok(());
|
|
}
|
|
return Err(OAuthError::transport(
|
|
"test identity OAuth endpoint must resolve only to loopback addresses",
|
|
));
|
|
}
|
|
if addrs
|
|
.iter()
|
|
.any(|addr| aether_http::is_private_or_reserved_ip(addr.ip()))
|
|
{
|
|
return Err(OAuthError::transport(
|
|
"identity OAuth endpoint resolves to a private or reserved address",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn resolve_identity_oauth_endpoint(
|
|
raw_url: &str,
|
|
policy: IdentityOAuthEndpointPolicy,
|
|
) -> Result<ResolvedIdentityOAuthEndpoint, OAuthError> {
|
|
let (url, host, port) = parse_identity_oauth_endpoint(raw_url, policy)?;
|
|
let addrs = if let Ok(ip) = host.parse::<IpAddr>() {
|
|
vec![SocketAddr::new(ip, port)]
|
|
} else {
|
|
aether_http::lookup_host_with_limits(
|
|
host.as_str(),
|
|
port,
|
|
aether_http::DEFAULT_DNS_LOOKUP_TIMEOUT,
|
|
)
|
|
.await
|
|
.map_err(|_| OAuthError::transport("identity OAuth endpoint DNS resolution failed"))?
|
|
};
|
|
validate_identity_oauth_resolved_addrs(&addrs, policy)?;
|
|
Ok(ResolvedIdentityOAuthEndpoint { url, host, addrs })
|
|
}
|
|
|
|
fn build_pinned_identity_oauth_client(
|
|
host: &str,
|
|
addrs: &[SocketAddr],
|
|
timeouts: OAuthTimeouts,
|
|
) -> Result<reqwest::Client, OAuthError> {
|
|
reqwest::Client::builder()
|
|
.no_proxy()
|
|
.redirect(identity_oauth_redirect_policy())
|
|
.connect_timeout(Duration::from_millis(timeouts.connect_ms))
|
|
.read_timeout(Duration::from_millis(timeouts.read_ms))
|
|
.timeout(Duration::from_millis(timeouts.total_ms))
|
|
.resolve_to_addrs(host, addrs)
|
|
.build()
|
|
.map_err(|_| OAuthError::transport("identity OAuth HTTP client initialization failed"))
|
|
}
|
|
|
|
fn identity_oauth_redirect_policy() -> reqwest::redirect::Policy {
|
|
reqwest::redirect::Policy::none()
|
|
}
|
|
|
|
fn identity_oauth_headers(
|
|
headers: &BTreeMap<String, String>,
|
|
content_type: Option<&str>,
|
|
) -> Result<HeaderMap, OAuthError> {
|
|
let mut result = HeaderMap::new();
|
|
for (name, value) in headers {
|
|
if name.eq_ignore_ascii_case(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER) {
|
|
continue;
|
|
}
|
|
let name = HeaderName::from_bytes(name.as_bytes())
|
|
.map_err(|_| OAuthError::transport("identity OAuth request has an invalid header"))?;
|
|
let value = HeaderValue::from_str(value)
|
|
.map_err(|_| OAuthError::transport("identity OAuth request has an invalid header"))?;
|
|
result.insert(name, value);
|
|
}
|
|
if !result.contains_key(CONTENT_TYPE) {
|
|
if let Some(content_type) = content_type {
|
|
let value = HeaderValue::from_str(content_type).map_err(|_| {
|
|
OAuthError::transport("identity OAuth request has an invalid content type")
|
|
})?;
|
|
result.insert(CONTENT_TYPE, value);
|
|
}
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
async fn execute_direct_identity_oauth(
|
|
app: &AppState,
|
|
request: OAuthHttpRequest,
|
|
) -> Result<OAuthHttpResponse, OAuthError> {
|
|
let endpoint_policy = identity_oauth_endpoint_policy(app, &request.url);
|
|
let target = resolve_identity_oauth_endpoint(&request.url, endpoint_policy).await?;
|
|
let client = build_pinned_identity_oauth_client(
|
|
target.host.as_str(),
|
|
&target.addrs,
|
|
request.network.timeouts,
|
|
)?;
|
|
let headers = identity_oauth_headers(&request.headers, request.content_type.as_deref())?;
|
|
let mut builder = client.request(request.method, target.url).headers(headers);
|
|
if let Some(json_body) = request.json_body {
|
|
builder = builder.json(&json_body);
|
|
} else if let Some(body_bytes) = request.body_bytes {
|
|
builder = builder.body(body_bytes);
|
|
}
|
|
|
|
let response = builder
|
|
.send()
|
|
.await
|
|
.map_err(|_| OAuthError::transport("identity OAuth request failed"))?;
|
|
let status_code = response.status().as_u16();
|
|
let content_encoding = response
|
|
.headers()
|
|
.get(CONTENT_ENCODING)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(ToOwned::to_owned);
|
|
let body_bytes = collect_identity_oauth_response_body(response).await?;
|
|
let decoded = decode_response_bytes_with_limit(
|
|
&body_bytes,
|
|
content_encoding.as_deref(),
|
|
OAUTH_RESPONSE_BODY_LIMIT_BYTES,
|
|
)?
|
|
.unwrap_or(body_bytes);
|
|
Ok(OAuthHttpResponse {
|
|
status_code,
|
|
body_text: String::from_utf8_lossy(&decoded).to_string(),
|
|
json_body: serde_json::from_slice(&decoded).ok(),
|
|
})
|
|
}
|
|
|
|
async fn collect_identity_oauth_response_body(
|
|
response: reqwest::Response,
|
|
) -> Result<Vec<u8>, OAuthError> {
|
|
if response
|
|
.content_length()
|
|
.is_some_and(|length| length > OAUTH_RESPONSE_BODY_LIMIT_BYTES as u64)
|
|
{
|
|
return Err(oauth_response_too_large());
|
|
}
|
|
|
|
let mut body = Vec::new();
|
|
let mut stream = response.bytes_stream();
|
|
while let Some(chunk) = stream.next().await {
|
|
let chunk =
|
|
chunk.map_err(|_| OAuthError::transport("identity OAuth response body read failed"))?;
|
|
if chunk.len() > OAUTH_RESPONSE_BODY_LIMIT_BYTES.saturating_sub(body.len()) {
|
|
return Err(oauth_response_too_large());
|
|
}
|
|
body.extend_from_slice(&chunk);
|
|
}
|
|
Ok(body)
|
|
}
|
|
|
|
fn oauth_response_too_large() -> OAuthError {
|
|
OAuthError::transport(format!(
|
|
"OAuth response body exceeds {OAUTH_RESPONSE_BODY_LIMIT_BYTES} bytes"
|
|
))
|
|
}
|
|
|
|
fn execution_result_to_oauth_response(result: &ExecutionResult) -> OAuthHttpResponse {
|
|
OAuthHttpResponse {
|
|
status_code: result.status_code,
|
|
body_text: execution_body_text(result),
|
|
json_body: execution_json_body(result),
|
|
}
|
|
}
|
|
|
|
fn execution_json_body(result: &ExecutionResult) -> Option<serde_json::Value> {
|
|
result
|
|
.body
|
|
.as_ref()
|
|
.and_then(|body| body.json_body.clone())
|
|
.or_else(|| {
|
|
result
|
|
.body
|
|
.as_ref()
|
|
.and_then(|body| execution_body_bytes(&result.headers, body))
|
|
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
|
|
})
|
|
}
|
|
|
|
fn execution_body_text(result: &ExecutionResult) -> String {
|
|
result
|
|
.body
|
|
.as_ref()
|
|
.and_then(|body| execution_body_bytes(&result.headers, body))
|
|
.map(|bytes| String::from_utf8_lossy(&bytes).to_string())
|
|
.or_else(|| {
|
|
result
|
|
.body
|
|
.as_ref()
|
|
.and_then(|body| body.json_body.as_ref())
|
|
.and_then(|value| serde_json::to_string(value).ok())
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn execution_body_bytes(
|
|
headers: &BTreeMap<String, String>,
|
|
body: &aether_contracts::ResponseBody,
|
|
) -> Option<Vec<u8>> {
|
|
let bytes = body.body_bytes_b64.as_deref().and_then(|value| {
|
|
crate::execution_runtime::transport::decode_base64_body_with_limit(
|
|
value,
|
|
OAUTH_RESPONSE_BODY_LIMIT_BYTES,
|
|
)
|
|
.ok()
|
|
})?;
|
|
decode_response_bytes_with_limit(
|
|
&bytes,
|
|
headers.get("content-encoding").map(String::as_str),
|
|
OAUTH_RESPONSE_BODY_LIMIT_BYTES,
|
|
)
|
|
.ok()
|
|
.flatten()
|
|
.or(Some(bytes))
|
|
}
|
|
|
|
fn decode_response_bytes_with_limit(
|
|
bytes: &[u8],
|
|
content_encoding: Option<&str>,
|
|
limit_bytes: usize,
|
|
) -> Result<Option<Vec<u8>>, OAuthError> {
|
|
match content_encoding
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(str::to_ascii_lowercase)
|
|
.as_deref()
|
|
{
|
|
Some("gzip") => {
|
|
let mut decoder = GzDecoder::new(bytes);
|
|
read_oauth_response_decoder(&mut decoder, limit_bytes).map(Some)
|
|
}
|
|
Some("deflate") => {
|
|
let mut decoder = DeflateDecoder::new(bytes);
|
|
read_oauth_response_decoder(&mut decoder, limit_bytes).map(Some)
|
|
}
|
|
_ => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn read_oauth_response_decoder(
|
|
decoder: &mut impl Read,
|
|
limit_bytes: usize,
|
|
) -> Result<Vec<u8>, OAuthError> {
|
|
let read_limit = u64::try_from(limit_bytes)
|
|
.unwrap_or(u64::MAX)
|
|
.saturating_add(1);
|
|
let mut limited = decoder.take(read_limit);
|
|
let mut out = Vec::new();
|
|
limited
|
|
.read_to_end(&mut out)
|
|
.map_err(|_| OAuthError::transport("OAuth response body decompression failed"))?;
|
|
if out.len() > limit_bytes {
|
|
return Err(oauth_response_too_large());
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn gateway_error_to_oauth_error(error: GatewayError) -> OAuthError {
|
|
OAuthError::Transport(error.into_message())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
decode_response_bytes_with_limit, execution_body_bytes, identity_oauth_endpoint_policy,
|
|
identity_oauth_redirect_policy, identity_oauth_route, oauth_execution_plan,
|
|
parse_identity_oauth_endpoint, resolve_identity_oauth_endpoint,
|
|
validate_identity_oauth_resolved_addrs, IdentityOAuthEndpointPolicy, IdentityOAuthRoute,
|
|
};
|
|
use aether_contracts::{
|
|
ProxySnapshot, ResponseBody, EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER,
|
|
};
|
|
use aether_oauth::network::{
|
|
OAuthHttpRequest, OAuthNetworkContext, OAuthNetworkPolicy, OAuthTimeouts,
|
|
};
|
|
use std::collections::BTreeMap;
|
|
use std::io::Write;
|
|
use std::net::SocketAddr;
|
|
|
|
fn identity_request(proxy: Option<ProxySnapshot>) -> OAuthHttpRequest {
|
|
OAuthHttpRequest {
|
|
request_id: "identity-oauth:test".to_string(),
|
|
method: reqwest::Method::GET,
|
|
url: "https://oauth.example.test/userinfo".to_string(),
|
|
headers: BTreeMap::new(),
|
|
content_type: None,
|
|
json_body: None,
|
|
body_bytes: None,
|
|
network: OAuthNetworkContext {
|
|
policy: OAuthNetworkPolicy::DirectOrSystemProxy,
|
|
requirement: aether_oauth::network::NetworkRequirement::Optional,
|
|
proxy,
|
|
timeouts: OAuthTimeouts::DIRECT_DEFAULT,
|
|
},
|
|
transport_profile: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn private_ip_literal_is_rejected_before_connect() {
|
|
let error = resolve_identity_oauth_endpoint(
|
|
"https://127.0.0.1/token",
|
|
IdentityOAuthEndpointPolicy::PublicHttps,
|
|
)
|
|
.await
|
|
.expect_err("loopback identity endpoint must be rejected");
|
|
|
|
assert!(error.to_string().contains("private or reserved"));
|
|
}
|
|
|
|
#[test]
|
|
fn public_https_endpoint_and_resolved_addresses_are_accepted_without_dns() {
|
|
let (url, host, port) = parse_identity_oauth_endpoint(
|
|
"https://oauth.example.test/token?flow=login",
|
|
IdentityOAuthEndpointPolicy::PublicHttps,
|
|
)
|
|
.expect("public HTTPS URL should parse");
|
|
let addrs = ["8.8.8.8:443".parse::<SocketAddr>().unwrap()];
|
|
|
|
assert_eq!(url.scheme(), "https");
|
|
assert_eq!(host, "oauth.example.test");
|
|
assert_eq!(port, 443);
|
|
validate_identity_oauth_resolved_addrs(&addrs, IdentityOAuthEndpointPolicy::PublicHttps)
|
|
.expect("controlled public address should pass validation");
|
|
}
|
|
|
|
#[test]
|
|
fn identity_oauth_rejects_credentials_fragments_and_any_private_dns_answer() {
|
|
assert!(parse_identity_oauth_endpoint(
|
|
"https://user:[email protected]/token",
|
|
IdentityOAuthEndpointPolicy::PublicHttps,
|
|
)
|
|
.is_err());
|
|
assert!(parse_identity_oauth_endpoint(
|
|
"https://example.test/token#secret",
|
|
IdentityOAuthEndpointPolicy::PublicHttps,
|
|
)
|
|
.is_err());
|
|
assert!(parse_identity_oauth_endpoint(
|
|
"http://example.test/token",
|
|
IdentityOAuthEndpointPolicy::PublicHttps,
|
|
)
|
|
.is_err());
|
|
|
|
let mixed = [
|
|
"8.8.8.8:443".parse::<SocketAddr>().unwrap(),
|
|
"10.0.0.4:443".parse::<SocketAddr>().unwrap(),
|
|
];
|
|
assert!(validate_identity_oauth_resolved_addrs(
|
|
&mixed,
|
|
IdentityOAuthEndpointPolicy::PublicHttps,
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_test_endpoint_policy_only_allows_loopback_literals() {
|
|
let (_, host, port) = parse_identity_oauth_endpoint(
|
|
"http://127.0.0.1:32123/token",
|
|
IdentityOAuthEndpointPolicy::ExplicitTestLoopback,
|
|
)
|
|
.expect("explicit test loopback URL should parse");
|
|
assert_eq!(host, "127.0.0.1");
|
|
assert_eq!(port, 32123);
|
|
validate_identity_oauth_resolved_addrs(
|
|
&["127.0.0.1:32123".parse().unwrap()],
|
|
IdentityOAuthEndpointPolicy::ExplicitTestLoopback,
|
|
)
|
|
.expect("loopback resolution should be accepted for an explicit test endpoint");
|
|
|
|
assert!(parse_identity_oauth_endpoint(
|
|
"http://10.0.0.1/token",
|
|
IdentityOAuthEndpointPolicy::ExplicitTestLoopback,
|
|
)
|
|
.is_err());
|
|
assert!(parse_identity_oauth_endpoint(
|
|
"http://localhost/token",
|
|
IdentityOAuthEndpointPolicy::ExplicitTestLoopback,
|
|
)
|
|
.is_err());
|
|
assert!(validate_identity_oauth_resolved_addrs(
|
|
&["10.0.0.1:80".parse().unwrap()],
|
|
IdentityOAuthEndpointPolicy::ExplicitTestLoopback,
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_loopback_policy_requires_a_registered_loopback_origin_and_path() {
|
|
let app = crate::AppState::new()
|
|
.expect("gateway state should build")
|
|
.with_provider_oauth_token_url_for_tests("codex", "http://127.0.0.1:32123/oauth")
|
|
.with_provider_oauth_token_url_for_tests("bad", "http://10.0.0.1/token");
|
|
|
|
assert_eq!(
|
|
identity_oauth_endpoint_policy(&app, "http://127.0.0.1:32123/oauth/token"),
|
|
IdentityOAuthEndpointPolicy::ExplicitTestLoopback
|
|
);
|
|
assert_eq!(
|
|
identity_oauth_endpoint_policy(&app, "http://127.0.0.1:32123/oauth2/token"),
|
|
IdentityOAuthEndpointPolicy::PublicHttps
|
|
);
|
|
assert_eq!(
|
|
identity_oauth_endpoint_policy(&app, "http://127.0.0.1:32124/oauth/token"),
|
|
IdentityOAuthEndpointPolicy::PublicHttps
|
|
);
|
|
assert_eq!(
|
|
identity_oauth_endpoint_policy(&app, "http://10.0.0.1/token"),
|
|
IdentityOAuthEndpointPolicy::PublicHttps
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identity_oauth_disables_redirects_for_direct_and_tunnel_requests() {
|
|
assert_eq!(
|
|
format!("{:?}", identity_oauth_redirect_policy()),
|
|
"Policy(None)"
|
|
);
|
|
|
|
let tunnel = ProxySnapshot {
|
|
enabled: Some(true),
|
|
mode: Some("tunnel".to_string()),
|
|
node_id: Some("node-1".to_string()),
|
|
..ProxySnapshot::default()
|
|
};
|
|
assert!(
|
|
identity_oauth_route(OAuthNetworkPolicy::DirectOrSystemProxy, Some(&tunnel)).is_err()
|
|
);
|
|
let plan = oauth_execution_plan(identity_request(None), true);
|
|
assert_eq!(
|
|
plan.headers
|
|
.get(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER)
|
|
.map(String::as_str),
|
|
Some("false")
|
|
);
|
|
assert_eq!(
|
|
crate::execution_runtime::transport::execution_plan_response_body_limit_bytes(&plan),
|
|
super::OAUTH_RESPONSE_BODY_LIMIT_BYTES
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identity_oauth_rejects_forward_proxy_snapshots() {
|
|
let proxy = ProxySnapshot {
|
|
enabled: Some(true),
|
|
mode: Some("http".to_string()),
|
|
url: Some("http://proxy.example.test:8080".to_string()),
|
|
..ProxySnapshot::default()
|
|
};
|
|
|
|
assert!(
|
|
identity_oauth_route(OAuthNetworkPolicy::DirectOrSystemProxy, Some(&proxy)).is_err()
|
|
);
|
|
assert_eq!(
|
|
identity_oauth_route(OAuthNetworkPolicy::DirectOrSystemProxy, None).unwrap(),
|
|
IdentityOAuthRoute::Direct
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identity_oauth_rejects_enabled_tunnel_even_when_node_id_is_present() {
|
|
let proxy = ProxySnapshot {
|
|
enabled: Some(true),
|
|
mode: Some("tunnel".to_string()),
|
|
node_id: Some("node-1".to_string()),
|
|
..ProxySnapshot::default()
|
|
};
|
|
assert!(
|
|
identity_oauth_route(OAuthNetworkPolicy::DirectOrSystemProxy, Some(&proxy)).is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn oauth_response_decoder_rejects_decompression_bombs() {
|
|
let payload = b"123456789";
|
|
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
|
encoder
|
|
.write_all(payload)
|
|
.expect("gzip payload should write");
|
|
let encoded = encoder.finish().expect("gzip payload should finish");
|
|
|
|
let error = decode_response_bytes_with_limit(&encoded, Some("gzip"), 8)
|
|
.expect_err("decoded OAuth body above the limit must fail closed");
|
|
|
|
assert!(error.to_string().contains("exceeds"));
|
|
}
|
|
|
|
#[test]
|
|
fn oauth_execution_body_rejects_oversized_base64_before_decode() {
|
|
let encoded_limit =
|
|
crate::execution_runtime::transport::maximum_base64_len_for_decoded_limit(
|
|
super::OAUTH_RESPONSE_BODY_LIMIT_BYTES,
|
|
);
|
|
let body = ResponseBody {
|
|
json_body: None,
|
|
body_bytes_b64: Some("A".repeat(encoded_limit + 1)),
|
|
};
|
|
|
|
assert!(execution_body_bytes(&BTreeMap::new(), &body).is_none());
|
|
}
|
|
}
|