diff --git a/apps/aether-gateway/src/handlers/admin/auth/oauth_routes.rs b/apps/aether-gateway/src/handlers/admin/auth/oauth_routes.rs index f15ccd1c8..759f54f9f 100644 --- a/apps/aether-gateway/src/handlers/admin/auth/oauth_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/auth/oauth_routes.rs @@ -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), ()> { + 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), ()> { 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( diff --git a/apps/aether-gateway/src/handlers/shared/payment_direct.rs b/apps/aether-gateway/src/handlers/shared/payment_direct.rs index 4af0fff39..408fb2f2f 100644 --- a/apps/aether-gateway/src/handlers/shared/payment_direct.rs +++ b/apps/aether-gateway/src/handlers/shared/payment_direct.rs @@ -899,13 +899,7 @@ pub(crate) async fn public_payment_http_client(url: &url::Url) -> Result Result 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 { 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()); + } + } }