产物地址只允许公网主机,回环与内网段一律拒收

- platform-tripo:`TripoUrl::parse` 在协议与 userinfo 检查之后,再加一道主机检查——回环、内网、链路本地、组播与保留段(含云元数据 169.254.169.254)全部拒收;`localhost` 及其子域同样拒收;IPv4-mapped 的 IPv6 地址按内嵌的 IPv4 判定,`::ffff:127.0.0.1` 绕不过去
- 保留段按 IANA 特殊用途表逐段列出(0/8、100.64/10、192.0.0/24、198.18/15、240/4),因为 `Ipv4Addr::is_reserved` 与 `is_global` 在当前工具链上仍是 nightly
- 新增用例覆盖 17 个拒收地址与 5 个放行地址;产物下载用例本来就跑在 127.0.0.1 的本地 mock 上,改用 `parse_unchecked_for_test` 构造
- docs/technical:写明这条主机规则与「域名解析结果在这一层看不见」的已知限制
This commit is contained in:
2026-09-24 20:36:57 +08:00
parent a6409d79dc
commit a08e3bf262
3 changed files with 128 additions and 3 deletions
@@ -16,7 +16,7 @@
`platform-tripo` 是唯一接触 `tripo3d-sdk` 的边界。它返回自己的 provider DTO,不让 SDK 类型穿透到未来的 `api-server`。Tripo 的 `success` 映射为 `completed`;`failed`/`banned` 映射为 `failed`;`cancelled` 和 `expired` 保留为独立终态;未知状态返回结构化错误。
provider task 结果与产品资源结果分离。provider adapter 不生成 `resourceId` 或 `assetId`;未来应用层在资源持久化后再构造带资源 ID 的产品 DTO。模型 URL 被视为临时 provider 引用,下载由显式方法完成。`TripoUrl` 的 `Debug` 与 `redacted()` 只输出 scheme、host 与 path,隐藏可能带签名的 query 与 fragment,并保留命中的分隔符(`?` 或 `#`),不把 fragment 显示成 query;带 userinfo 的地址在解析阶段直接拒绝。完整 URL 仅通过 `as_str()` 显式读取;`TripoUrl` 不实现 `Display`,避免 `{}` 这类通用格式化把带签名的完整地址写进日志,冒烟示例同样只打印脱敏后的地址。
provider task 结果与产品资源结果分离。provider adapter 不生成 `resourceId` 或 `assetId`;未来应用层在资源持久化后再构造带资源 ID 的产品 DTO。模型 URL 被视为临时 provider 引用,下载由显式方法完成。`TripoUrl` 的 `Debug` 与 `redacted()` 只输出 scheme、host 与 path,隐藏可能带签名的 query 与 fragment,并保留命中的分隔符(`?` 或 `#`),不把 fragment 显示成 query;带 userinfo 的地址在解析阶段直接拒绝;主机必须是公网地址,回环、内网、链路本地、组播与保留段(含云元数据地址 `169.254.169.254`)一律拒收,域名层面拦住 `localhost` 及其子域。域名解析出的真实地址在这一层看不见,域名指向内网的情况要靠连接层处理,属已知限制。完整 URL 仅通过 `as_str()` 显式读取;`TripoUrl` 不实现 `Display`,避免 `{}` 这类通用格式化把带签名的完整地址写进日志,冒烟示例同样只打印脱敏后的地址。
task 尚未完成时 `output` 为空;完成后 `output` 必须是与 task type 一致的 enum variant,不使用把不同 endpoint 字段揉在一起的通用可选字段结构。真实 provider smoke 确认:text-to-model 结果固定包含 `model_url`、`rendered_image_url`、`generated_image_url`;image-to-model 和 multiview-to-model 结果固定包含 `model_url`、`rendered_image_url`。这些字段在各自结果 struct 中均为必填 `TripoUrl`;缺失、URL 非法或 task type 不受支持时返回 `TripoError::OutputSchema`。
@@ -263,7 +263,7 @@ mod tests {
}
fn artifact_url(addr: SocketAddr) -> TripoUrl {
TripoUrl::parse(&format!("http://{addr}/model.glb")).expect("测试地址必须是合法产物 URL")
TripoUrl::parse_unchecked_for_test(&format!("http://{addr}/model.glb"))
}
async fn spawn_mock_server<F, Fut>(serve: F) -> SocketAddr
@@ -1,9 +1,10 @@
use std::fmt;
use std::net::{Ipv4Addr, Ipv6Addr};
use bytes::Bytes;
use reqwest::Response;
use url::Url;
use url::{Host, Url};
use shared_contracts::model3d::common::Model3dTaskStatus;
@@ -54,9 +55,30 @@ impl TripoUrl {
message: "URL must not contain userinfo".into(),
});
}
// 这个地址是要我们主动去请求的:本机回环 / 内网 / 链路本地 / 保留段一律拒收,
// 否则 provider 响应(或伪造的响应)就能把工作进程当成打内网的跳板,
// 云元数据地址 169.254.169.254 正落在被拒之列。
let host = url.host().ok_or_else(|| TripoError::OutputSchema {
task_id: "unknown".into(),
message: "URL must contain a host".into(),
})?;
if !host_is_public(host) {
return Err(TripoError::OutputSchema {
task_id: "unknown".into(),
message: "URL host must be a public address".into(),
});
}
Ok(Self(url))
}
/// 仅供本 crate 的测试夹具使用:跳过「主机必须是公网地址」的检查。
///
/// 产物下载的用例本来就跑在 `127.0.0.1` 的本地 mock 服务上,用真实口径构造不出来。
#[cfg(test)]
pub(crate) fn parse_unchecked_for_test(value: &str) -> Self {
Self(Url::parse(value).expect("测试夹具必须是合法 URL"))
}
/// 完整 URL 只通过该显式访问器读取。
pub fn as_str(&self) -> &str {
self.0.as_str()
@@ -81,6 +103,64 @@ impl TripoUrl {
}
}
/// 产物地址只允许公网主机:回环、内网、链路本地、组播与保留段都拒收。
///
/// 域名只拦最明显的本机名(`localhost` 及其子域);不在这里查 DNS —— 解析结果在这一层
/// 看不见,域名指向内网地址的情况要挡只能在连接层按解析出的地址判定。
fn host_is_public(host: Host<&str>) -> bool {
match host {
Host::Domain(name) => !is_local_host_name(name),
Host::Ipv4(ip) => ipv4_is_public(ip),
Host::Ipv6(ip) => ipv6_is_public(ip),
}
}
fn is_local_host_name(name: &str) -> bool {
let name = name.trim_end_matches('.').to_ascii_lowercase();
name == "localhost" || name.ends_with(".localhost")
}
fn ipv4_is_public(ip: Ipv4Addr) -> bool {
!(ip.is_unspecified()
|| ip.is_loopback()
|| ip.is_private()
|| ip.is_link_local()
|| ip.is_broadcast()
|| ip.is_multicast()
|| ip.is_documentation()
|| ipv4_is_reserved(ip))
}
/// 保留段按 IANA 特殊用途表逐段判定。
///
/// `Ipv4Addr::is_reserved` 目前还是 nightly 的(`ip` feature),所以这里自己列:
/// `0.0.0.0/8`、`100.64.0.0/10`(运营商级 NAT)、`192.0.0.0/24`(IETF 协议分配)、
/// `198.18.0.0/15`(基准测试)、`240.0.0.0/4`(保留,含 `255.255.255.255`)。
fn ipv4_is_reserved(ip: Ipv4Addr) -> bool {
match ip.octets() {
[0, ..] => true,
[100, second, ..] => (64..=127).contains(&second),
[192, 0, 0, _] => true,
[198, second, ..] => (18..=19).contains(&second),
[first, ..] => first >= 240,
}
}
fn ipv6_is_public(ip: Ipv6Addr) -> bool {
// IPv4-mapped 地址按内嵌的 IPv4 判定,否则 `::ffff:127.0.0.1` 能绕开回环检查。
if let Some(embedded) = ip.to_ipv4_mapped() {
return ipv4_is_public(embedded);
}
let segments = ip.segments();
!(ip.is_unspecified()
|| ip.is_loopback()
|| ip.is_multicast()
|| ip.is_unique_local()
|| ip.is_unicast_link_local()
// `2001:db8::/32` 是文档用地址;`Ipv6Addr::is_documentation` 同样是 nightly。
|| (segments[0] == 0x2001 && segments[1] == 0x0db8))
}
impl fmt::Debug for TripoUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("TripoUrl").field(&self.redacted()).finish()
@@ -350,6 +430,51 @@ mod tests {
TripoUrl::parse(&format!("https://cdn.example.com{path}")).expect("夹具地址必须合法")
}
#[test]
fn artifact_urls_must_point_at_a_public_host() {
for rejected in [
"http://127.0.0.1/model.glb",
"http://169.254.169.254/latest/meta-data/",
"http://10.1.2.3/model.glb",
"http://192.168.1.10/model.glb",
"http://172.16.0.9/model.glb",
"http://100.64.0.1/model.glb",
"http://198.18.0.1/model.glb",
"http://240.0.0.1/model.glb",
"http://0.0.0.0/model.glb",
"http://192.0.2.10/model.glb",
"http://[::1]/model.glb",
"http://[fd00::1]/model.glb",
"http://[fe80::1]/model.glb",
"http://[::ffff:127.0.0.1]/model.glb",
"http://[2001:db8::1]/model.glb",
"http://localhost/model.glb",
"http://CDN.LOCALHOST./model.glb",
] {
assert!(
TripoUrl::parse(rejected).is_err(),
"非公网主机必须拒收:{rejected}"
);
}
for accepted in [
"https://openapi.cdn.tripo3d.com/a/model.glb",
"http://1.1.1.1/model.glb",
"http://8.8.8.8/model.glb",
"http://[2606:4700::1111]/model.glb",
"http://198.20.0.1/model.glb",
"http://192.0.1.1/model.glb",
] {
assert!(
TripoUrl::parse(accepted).is_ok(),
"公网主机必须放行:{accepted}"
);
}
// `192.0.0.0/24` 是 IETF 协议分配段:段内拒收,紧邻的 `192.0.1.0/24` 不受影响。
assert!(TripoUrl::parse("http://192.0.0.9/model.glb").is_err());
assert!(TripoUrl::parse("http://192.0.1.1/model.glb").is_ok());
}
#[test]
fn path_like_and_oversized_stems_are_neutralized_and_capped() {
let name = format!("{}../../etc/passwd", "a".repeat(400));