mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37: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 {
|
let Ok(mut current) = reqwest::Url::parse(url) else {
|
||||||
return false;
|
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() {
|
if validate_admin_oauth_url_override(current.as_str(), allowed_domains).is_err() {
|
||||||
return false;
|
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;
|
return false;
|
||||||
};
|
};
|
||||||
let mut builder = reqwest::Client::builder()
|
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(
|
async fn resolve_public_admin_oauth_endpoint(
|
||||||
url: &reqwest::Url,
|
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>), ()> {
|
) -> Result<(String, Vec<SocketAddr>), ()> {
|
||||||
if url.scheme() != "https"
|
if url.scheme() != "https"
|
||||||
|| !url.username().is_empty()
|
|| !url.username().is_empty()
|
||||||
@@ -112,16 +125,43 @@ async fn resolve_public_admin_oauth_endpoint(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| ())?
|
.map_err(|_| ())?
|
||||||
};
|
};
|
||||||
if addrs.is_empty()
|
if validate_public_admin_oauth_resolved_addrs(url, &addrs, allow_benchmarking_ip).is_err() {
|
||||||
|| addrs
|
|
||||||
.iter()
|
|
||||||
.any(|addr| aether_http::is_private_or_reserved_ip(addr.ip()))
|
|
||||||
{
|
|
||||||
return Err(());
|
return Err(());
|
||||||
}
|
}
|
||||||
Ok((host.to_string(), addrs))
|
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(
|
fn admin_oauth_test_allowed_domains(
|
||||||
provider_type: &str,
|
provider_type: &str,
|
||||||
payload: &serde_json::Value,
|
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!(
|
let (authorization_url_reachable, token_url_reachable) = tokio::join!(
|
||||||
admin_oauth_endpoint_reachable(&authorization_url, &allowed_domain_refs),
|
admin_oauth_endpoint_reachable(
|
||||||
admin_oauth_endpoint_reachable(&token_url, &allowed_domain_refs),
|
&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 {
|
let details = if authorization_url_reachable && token_url_reachable {
|
||||||
@@ -296,7 +341,11 @@ async fn build_admin_oauth_test_payload(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[tokio::test]
|
||||||
async fn oauth_test_endpoint_rejects_loopback_https_targets_before_connecting() {
|
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());
|
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(
|
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
|
.await
|
||||||
.map_err(|_| "支付网关 DNS 解析失败".to_string())?
|
.map_err(|_| "支付网关 DNS 解析失败".to_string())?
|
||||||
};
|
};
|
||||||
if addrs.is_empty()
|
validate_public_payment_resolved_addrs(url, &addrs)?;
|
||||||
|| addrs
|
|
||||||
.iter()
|
|
||||||
.any(|addr| aether_http::is_private_or_reserved_ip(addr.ip()))
|
|
||||||
{
|
|
||||||
return Err("支付网关解析到私有或保留地址".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut builder = reqwest::Client::builder()
|
let mut builder = reqwest::Client::builder()
|
||||||
.no_proxy()
|
.no_proxy()
|
||||||
@@ -918,6 +912,35 @@ pub(crate) async fn public_payment_http_client(url: &url::Url) -> Result<reqwest
|
|||||||
.map_err(|_| "支付网关 HTTP 客户端初始化失败".to_string())
|
.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> {
|
fn wxpay_config_string(config: &DirectGatewayConfig, key: &str) -> Result<String, String> {
|
||||||
config_string(&config.config, key).ok_or_else(|| format!("微信支付 {key} 未配置"))
|
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,
|
alipay_precreate_business_refusal, decode_payment_base64_with_limit, gateway_refund_proof,
|
||||||
payment_callback_key, payment_callback_projection, payment_payload_hash,
|
payment_callback_key, payment_callback_projection, payment_payload_hash,
|
||||||
public_payment_http_client, rsa_sha256_sign_base64, rsa_sha256_verify_base64,
|
public_payment_http_client, rsa_sha256_sign_base64, rsa_sha256_verify_base64,
|
||||||
validated_payment_identifier, wxpay_notify_payment_channel, wxpay_refund_status,
|
validate_public_payment_resolved_addrs, validated_payment_identifier,
|
||||||
DirectGatewayConfig, DirectGatewayRefundResult, MAX_PAYMENT_GATEWAY_ID_BYTES,
|
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::encoding::{AsDer, Pkcs8V1Der, PublicKeyX509Der};
|
||||||
use aws_lc_rs::rsa::{KeyPair as AwsRsaKeyPair, KeySize};
|
use aws_lc_rs::rsa::{KeyPair as AwsRsaKeyPair, KeySize};
|
||||||
@@ -1755,6 +1779,7 @@ mod tests {
|
|||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn direct_gateway_config_debug_output_redacts_decrypted_secrets() {
|
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