fix(network): scope synthetic DNS exceptions for oauth and payments

This commit is contained in:
elky
2026-09-04 18:08:22 +08:00
parent c005700a7e
commit 635c6765d9
2 changed files with 161 additions and 19 deletions
@@ -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(&current).await else {
let Ok((host, addrs)) =
resolve_public_admin_oauth_endpoint_with_policy(&current, 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());
}
}
}