mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
fix(network): scope synthetic DNS exceptions for oauth and payments
This commit is contained in:
@@ -39,7 +39,11 @@ fn admin_oauth_secret_status(has_secret: bool) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
async fn admin_oauth_endpoint_reachable(url: &str, allowed_domains: &[&str]) -> bool {
|
||||
async fn admin_oauth_endpoint_reachable(
|
||||
url: &str,
|
||||
allowed_domains: &[&str],
|
||||
allow_benchmarking_ip: bool,
|
||||
) -> bool {
|
||||
let Ok(mut current) = reqwest::Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
@@ -47,7 +51,9 @@ async fn admin_oauth_endpoint_reachable(url: &str, allowed_domains: &[&str]) ->
|
||||
if validate_admin_oauth_url_override(current.as_str(), allowed_domains).is_err() {
|
||||
return false;
|
||||
}
|
||||
let Ok((host, addrs)) = resolve_public_admin_oauth_endpoint(¤t).await else {
|
||||
let Ok((host, addrs)) =
|
||||
resolve_public_admin_oauth_endpoint_with_policy(¤t, allow_benchmarking_ip).await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut builder = reqwest::Client::builder()
|
||||
@@ -95,6 +101,13 @@ async fn admin_oauth_endpoint_reachable(url: &str, allowed_domains: &[&str]) ->
|
||||
|
||||
async fn resolve_public_admin_oauth_endpoint(
|
||||
url: &reqwest::Url,
|
||||
) -> Result<(String, Vec<SocketAddr>), ()> {
|
||||
resolve_public_admin_oauth_endpoint_with_policy(url, false).await
|
||||
}
|
||||
|
||||
async fn resolve_public_admin_oauth_endpoint_with_policy(
|
||||
url: &reqwest::Url,
|
||||
allow_benchmarking_ip: bool,
|
||||
) -> Result<(String, Vec<SocketAddr>), ()> {
|
||||
if url.scheme() != "https"
|
||||
|| !url.username().is_empty()
|
||||
@@ -112,16 +125,43 @@ async fn resolve_public_admin_oauth_endpoint(
|
||||
.await
|
||||
.map_err(|_| ())?
|
||||
};
|
||||
if addrs.is_empty()
|
||||
|| addrs
|
||||
.iter()
|
||||
.any(|addr| aether_http::is_private_or_reserved_ip(addr.ip()))
|
||||
{
|
||||
if validate_public_admin_oauth_resolved_addrs(url, &addrs, allow_benchmarking_ip).is_err() {
|
||||
return Err(());
|
||||
}
|
||||
Ok((host.to_string(), addrs))
|
||||
}
|
||||
|
||||
fn validate_public_admin_oauth_resolved_addrs(
|
||||
url: &reqwest::Url,
|
||||
addrs: &[SocketAddr],
|
||||
allow_benchmarking_ip: bool,
|
||||
) -> Result<(), ()> {
|
||||
if addrs.is_empty()
|
||||
|| addrs.iter().any(|addr| {
|
||||
aether_http::is_private_or_reserved_ip(addr.ip())
|
||||
&& !(allow_benchmarking_ip
|
||||
&& is_fixed_linuxdo_oauth_origin(url)
|
||||
&& aether_http::is_ipv4_benchmarking_fake_ip(addr.ip()))
|
||||
})
|
||||
{
|
||||
return Err(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_fixed_linuxdo_oauth_origin(url: &reqwest::Url) -> bool {
|
||||
url.scheme() == "https"
|
||||
&& url.host_str().is_some_and(|host| {
|
||||
host.trim_end_matches('.')
|
||||
.eq_ignore_ascii_case("connect.linux.do")
|
||||
})
|
||||
&& url.port_or_known_default() == Some(443)
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.query().is_none()
|
||||
&& url.fragment().is_none()
|
||||
}
|
||||
|
||||
fn admin_oauth_test_allowed_domains(
|
||||
provider_type: &str,
|
||||
payload: &serde_json::Value,
|
||||
@@ -275,9 +315,14 @@ async fn build_admin_oauth_test_payload(
|
||||
}));
|
||||
}
|
||||
|
||||
let allow_benchmarking_ip = provider_type.eq_ignore_ascii_case("linuxdo");
|
||||
let (authorization_url_reachable, token_url_reachable) = tokio::join!(
|
||||
admin_oauth_endpoint_reachable(&authorization_url, &allowed_domain_refs),
|
||||
admin_oauth_endpoint_reachable(&token_url, &allowed_domain_refs),
|
||||
admin_oauth_endpoint_reachable(
|
||||
&authorization_url,
|
||||
&allowed_domain_refs,
|
||||
allow_benchmarking_ip,
|
||||
),
|
||||
admin_oauth_endpoint_reachable(&token_url, &allowed_domain_refs, allow_benchmarking_ip),
|
||||
);
|
||||
|
||||
let details = if authorization_url_reachable && token_url_reachable {
|
||||
@@ -296,7 +341,11 @@ async fn build_admin_oauth_test_payload(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_public_admin_oauth_endpoint;
|
||||
use super::{
|
||||
is_fixed_linuxdo_oauth_origin, resolve_public_admin_oauth_endpoint,
|
||||
validate_public_admin_oauth_resolved_addrs,
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_test_endpoint_rejects_loopback_https_targets_before_connecting() {
|
||||
@@ -304,6 +353,41 @@ mod tests {
|
||||
|
||||
assert!(resolve_public_admin_oauth_endpoint(&url).await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linuxdo_builtin_origin_allows_only_benchmarking_addresses() {
|
||||
let fixed = reqwest::Url::parse("https://connect.linux.do/oauth2/token")
|
||||
.expect("LinuxDo URL should parse");
|
||||
let fake = SocketAddr::from(([198, 18, 75, 234], 443));
|
||||
assert!(is_fixed_linuxdo_oauth_origin(&fixed));
|
||||
assert!(validate_public_admin_oauth_resolved_addrs(&fixed, &[fake], true).is_ok());
|
||||
assert!(validate_public_admin_oauth_resolved_addrs(&fixed, &[fake], false).is_err());
|
||||
assert!(validate_public_admin_oauth_resolved_addrs(
|
||||
&fixed,
|
||||
&[fake, SocketAddr::from(([127, 0, 0, 1], 443))],
|
||||
true,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_or_non_default_oauth_origins_reject_benchmarking_addresses() {
|
||||
let fake = SocketAddr::from(([198, 18, 75, 234], 443));
|
||||
for raw_url in [
|
||||
"https://oauth.example.test/token",
|
||||
"https://connect.linux.do:8443/oauth2/token",
|
||||
"https://connect.linuxdo.org/oauth2/token",
|
||||
"https://connect.linux.do.evil.test/oauth2/token",
|
||||
"https://connect.linux.do/oauth2/token?tenant=unexpected",
|
||||
] {
|
||||
let url = reqwest::Url::parse(raw_url).expect("test URL should parse");
|
||||
assert!(
|
||||
!is_fixed_linuxdo_oauth_origin(&url),
|
||||
"must not trust {raw_url}"
|
||||
);
|
||||
assert!(validate_public_admin_oauth_resolved_addrs(&url, &[fake], true).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_oauth_response(
|
||||
|
||||
@@ -899,13 +899,7 @@ pub(crate) async fn public_payment_http_client(url: &url::Url) -> Result<reqwest
|
||||
.await
|
||||
.map_err(|_| "支付网关 DNS 解析失败".to_string())?
|
||||
};
|
||||
if addrs.is_empty()
|
||||
|| addrs
|
||||
.iter()
|
||||
.any(|addr| aether_http::is_private_or_reserved_ip(addr.ip()))
|
||||
{
|
||||
return Err("支付网关解析到私有或保留地址".to_string());
|
||||
}
|
||||
validate_public_payment_resolved_addrs(url, &addrs)?;
|
||||
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
@@ -918,6 +912,35 @@ pub(crate) async fn public_payment_http_client(url: &url::Url) -> Result<reqwest
|
||||
.map_err(|_| "支付网关 HTTP 客户端初始化失败".to_string())
|
||||
}
|
||||
|
||||
fn validate_public_payment_resolved_addrs(
|
||||
url: &url::Url,
|
||||
addrs: &[SocketAddr],
|
||||
) -> Result<(), String> {
|
||||
if addrs.is_empty()
|
||||
|| addrs.iter().any(|addr| {
|
||||
aether_http::is_private_or_reserved_ip(addr.ip())
|
||||
&& !(is_fixed_stripe_api_origin(url)
|
||||
&& aether_http::is_ipv4_benchmarking_fake_ip(addr.ip()))
|
||||
})
|
||||
{
|
||||
return Err("支付网关解析到私有或保留地址".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_fixed_stripe_api_origin(url: &url::Url) -> bool {
|
||||
url.scheme() == "https"
|
||||
&& url.host_str().is_some_and(|host| {
|
||||
host.trim_end_matches('.')
|
||||
.eq_ignore_ascii_case("api.stripe.com")
|
||||
})
|
||||
&& url.port_or_known_default() == Some(443)
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.query().is_none()
|
||||
&& url.fragment().is_none()
|
||||
}
|
||||
|
||||
fn wxpay_config_string(config: &DirectGatewayConfig, key: &str) -> Result<String, String> {
|
||||
config_string(&config.config, key).ok_or_else(|| format!("微信支付 {key} 未配置"))
|
||||
}
|
||||
@@ -1746,8 +1769,9 @@ mod tests {
|
||||
alipay_precreate_business_refusal, decode_payment_base64_with_limit, gateway_refund_proof,
|
||||
payment_callback_key, payment_callback_projection, payment_payload_hash,
|
||||
public_payment_http_client, rsa_sha256_sign_base64, rsa_sha256_verify_base64,
|
||||
validated_payment_identifier, wxpay_notify_payment_channel, wxpay_refund_status,
|
||||
DirectGatewayConfig, DirectGatewayRefundResult, MAX_PAYMENT_GATEWAY_ID_BYTES,
|
||||
validate_public_payment_resolved_addrs, validated_payment_identifier,
|
||||
wxpay_notify_payment_channel, wxpay_refund_status, DirectGatewayConfig,
|
||||
DirectGatewayRefundResult, MAX_PAYMENT_GATEWAY_ID_BYTES,
|
||||
};
|
||||
use aws_lc_rs::encoding::{AsDer, Pkcs8V1Der, PublicKeyX509Der};
|
||||
use aws_lc_rs::rsa::{KeyPair as AwsRsaKeyPair, KeySize};
|
||||
@@ -1755,6 +1779,7 @@ mod tests {
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine as _;
|
||||
use serde_json::json;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[test]
|
||||
fn direct_gateway_config_debug_output_redacts_decrypted_secrets() {
|
||||
@@ -2074,4 +2099,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripe_api_origin_allows_only_benchmarking_addresses() {
|
||||
let fake = SocketAddr::from(([198, 18, 75, 234], 443));
|
||||
for raw_url in [
|
||||
"https://api.stripe.com/v1/payment_intents",
|
||||
"https://API.STRIPE.COM:443/v1/payment_intents",
|
||||
] {
|
||||
let url = url::Url::parse(raw_url).expect("Stripe URL should parse");
|
||||
assert!(validate_public_payment_resolved_addrs(&url, &[fake]).is_ok());
|
||||
}
|
||||
assert!(validate_public_payment_resolved_addrs(
|
||||
&url::Url::parse("https://api.stripe.com/v1/payment_intents").unwrap(),
|
||||
&[fake, SocketAddr::from(([127, 0, 0, 1], 443))],
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_or_non_default_payment_origins_reject_benchmarking_addresses() {
|
||||
let fake = SocketAddr::from(([198, 18, 75, 234], 443));
|
||||
for raw_url in [
|
||||
"https://payments.example.test/v1/payment_intents",
|
||||
"https://api.stripe.com:8443/v1/payment_intents",
|
||||
"http://api.stripe.com/v1/payment_intents",
|
||||
"https://api.stripe.com.evil.test/v1/payment_intents",
|
||||
"https://api.stripe.com/v1/payment_intents?redirect=internal",
|
||||
"https://api.stripe.com/v1/payment_intents#fragment",
|
||||
] {
|
||||
let url = url::Url::parse(raw_url).expect("test URL should parse");
|
||||
assert!(validate_public_payment_resolved_addrs(&url, &[fake]).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user