fix(delivery): reject private DNS probe targets
Filter resolved connection addresses and bypass environment proxies so project-declared probes cannot reach local services through public hostnames.
This commit is contained in:
parent
02b9f1ec36
commit
2b577f6902
@ -141,7 +141,7 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言:
|
|||||||
|
|
||||||
## production 交付观测边界
|
## production 交付观测边界
|
||||||
|
|
||||||
项目可自主管理 `.lian-jing/capabilities.yaml` 的 `delivery.targets.production`,声明 Gitea Actions 的**精确 status context** 与公开 HTTP JSON 版本探针。炼境只读该声明;接入包不生成或覆盖项目的 production 配置。探针不接受 URL 凭证、认证头、查询参数、非 HTTP(S) 协议或本机/内网字面地址,不跟随重定向,并限制请求时间与响应大小。
|
项目可自主管理 `.lian-jing/capabilities.yaml` 的 `delivery.targets.production`,声明 Gitea Actions 的**精确 status context** 与公开 HTTP JSON 版本探针。炼境只读该声明;接入包不生成或覆盖项目的 production 配置。探针不接受 URL 凭证、认证头、查询参数、非 HTTP(S) 协议或本机/内网地址;连接时过滤域名解析出的私网地址,不走环境代理、不跟随重定向,并限制请求时间与响应大小。
|
||||||
|
|
||||||
`get_delivery_status` 分别采集 Gitea 默认分支目标 SHA 的部署过程证据和当前服务报告的运行 SHA。只有部署 context 成功、探针健康、两个完整 SHA 相等时才报告 `deployed`。`failed` 保留实际运行版本,`drifted` 表示版本不一致;缺失/不可达证据明确降级。该状态仅是技术交付观测,不替代业务测试、上线决策,也不赋予炼境部署、回滚、SSH 或数据库写权限。
|
`get_delivery_status` 分别采集 Gitea 默认分支目标 SHA 的部署过程证据和当前服务报告的运行 SHA。只有部署 context 成功、探针健康、两个完整 SHA 相等时才报告 `deployed`。`failed` 保留实际运行版本,`drifted` 表示版本不一致;缺失/不可达证据明确降级。该状态仅是技术交付观测,不替代业务测试、上线决策,也不赋予炼境部署、回滚、SSH 或数据库写权限。
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,12 @@
|
|||||||
//! 信号与部署证据各自独立降级:任一来源不可用时必须明确标出,而不能猜测成功。
|
//! 信号与部署证据各自独立降级:任一来源不可用时必须明确标出,而不能猜测成功。
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::BTreeMap, io::Read, net::IpAddr, path::Path, process::Command, time::Duration,
|
collections::BTreeMap,
|
||||||
|
io::{self, Read},
|
||||||
|
net::{IpAddr, SocketAddr, ToSocketAddrs},
|
||||||
|
path::Path,
|
||||||
|
process::Command,
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -170,41 +175,63 @@ fn validate_probe_url(raw: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
let ip_host = host.trim_start_matches('[').trim_end_matches(']');
|
let ip_host = host.trim_start_matches('[').trim_end_matches(']');
|
||||||
if let Ok(ip) = ip_host.parse::<IpAddr>() {
|
if let Ok(ip) = ip_host.parse::<IpAddr>() {
|
||||||
let blocked = match ip {
|
if !is_public_probe_ip(ip) {
|
||||||
IpAddr::V4(ip) => {
|
|
||||||
ip.is_private()
|
|
||||||
|| ip.is_loopback()
|
|
||||||
|| ip.is_link_local()
|
|
||||||
|| ip.is_multicast()
|
|
||||||
|| ip.is_unspecified()
|
|
||||||
}
|
|
||||||
IpAddr::V6(ip) => {
|
|
||||||
ip.to_ipv4_mapped().is_some_and(|mapped| {
|
|
||||||
mapped.is_private()
|
|
||||||
|| mapped.is_loopback()
|
|
||||||
|| mapped.is_link_local()
|
|
||||||
|| mapped.is_multicast()
|
|
||||||
|| mapped.is_unspecified()
|
|
||||||
}) || ip.is_loopback()
|
|
||||||
|| ip.is_unique_local()
|
|
||||||
|| ip.is_unicast_link_local()
|
|
||||||
|| ip.is_multicast()
|
|
||||||
|| ip.is_unspecified()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if blocked {
|
|
||||||
return Err("探针不允许本机或内网 IP".into());
|
return Err("探针不允许本机或内网 IP".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_public_probe_ip(ip: IpAddr) -> bool {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(ip) => {
|
||||||
|
let octets = ip.octets();
|
||||||
|
!ip.is_private()
|
||||||
|
&& !ip.is_loopback()
|
||||||
|
&& !ip.is_link_local()
|
||||||
|
&& !ip.is_multicast()
|
||||||
|
&& !ip.is_unspecified()
|
||||||
|
&& !ip.is_broadcast()
|
||||||
|
&& octets[0] != 0
|
||||||
|
&& !(octets[0] == 100 && (64..=127).contains(&octets[1]))
|
||||||
|
}
|
||||||
|
IpAddr::V6(ip) => {
|
||||||
|
if let Some(ipv4) = ip.to_ipv4() {
|
||||||
|
return is_public_probe_ip(IpAddr::V4(ipv4));
|
||||||
|
}
|
||||||
|
!ip.is_loopback()
|
||||||
|
&& !ip.is_unique_local()
|
||||||
|
&& !ip.is_unicast_link_local()
|
||||||
|
&& !ip.is_multicast()
|
||||||
|
&& !ip.is_unspecified()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retain_public_probe_addresses(
|
||||||
|
addresses: impl IntoIterator<Item = SocketAddr>,
|
||||||
|
) -> io::Result<Vec<SocketAddr>> {
|
||||||
|
let safe: Vec<_> = addresses
|
||||||
|
.into_iter()
|
||||||
|
.filter(|address| is_public_probe_ip(address.ip()))
|
||||||
|
.collect();
|
||||||
|
if safe.is_empty() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::PermissionDenied,
|
||||||
|
"探针域名未解析到允许的公网地址",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(safe)
|
||||||
|
}
|
||||||
|
|
||||||
/// 探针只用项目声明的 URL 发起无认证 GET,不接受 Agent 动态 URL 或请求头。
|
/// 探针只用项目声明的 URL 发起无认证 GET,不接受 Agent 动态 URL 或请求头。
|
||||||
pub fn probe_http_json(probe: &HttpJsonProbe) -> ProbeEvidence {
|
pub fn probe_http_json(probe: &HttpJsonProbe) -> ProbeEvidence {
|
||||||
if let Err(detail) = validate_probe_url(&probe.url) {
|
if let Err(detail) = validate_probe_url(&probe.url) {
|
||||||
return ProbeEvidence::unavailable(detail);
|
return ProbeEvidence::unavailable(detail);
|
||||||
}
|
}
|
||||||
let agent = ureq::AgentBuilder::new()
|
let agent = ureq::AgentBuilder::new()
|
||||||
|
.try_proxy_from_env(false)
|
||||||
|
.resolver(|addr: &str| retain_public_probe_addresses(addr.to_socket_addrs()?))
|
||||||
.redirects(0)
|
.redirects(0)
|
||||||
.timeout(Duration::from_secs(4))
|
.timeout(Duration::from_secs(4))
|
||||||
.timeout_connect(Duration::from_secs(3))
|
.timeout_connect(Duration::from_secs(3))
|
||||||
@ -721,6 +748,20 @@ delivery:
|
|||||||
assert!(validate_probe_url("https://api3.yljt.info/health/version").is_ok());
|
assert!(validate_probe_url("https://api3.yljt.info/health/version").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_dns_resolver_never_returns_private_addresses() {
|
||||||
|
let loopback: SocketAddr = "127.0.0.1:443".parse().unwrap();
|
||||||
|
let private: SocketAddr = "192.168.1.1:443".parse().unwrap();
|
||||||
|
let carrier_nat: SocketAddr = "100.64.0.1:443".parse().unwrap();
|
||||||
|
let public: SocketAddr = "8.8.8.8:443".parse().unwrap();
|
||||||
|
assert!(retain_public_probe_addresses([loopback, private, carrier_nat]).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
retain_public_probe_addresses([loopback, public, private]).unwrap(),
|
||||||
|
vec![public]
|
||||||
|
);
|
||||||
|
assert!(validate_probe_url("http://100.64.0.1/version").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_profile_is_not_configured_and_probe_body_is_strict() {
|
fn missing_profile_is_not_configured_and_probe_body_is_strict() {
|
||||||
let empty = std::env::temp_dir().join(format!(
|
let empty = std::env::temp_dir().join(format!(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user