Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions crates/app-contract/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,12 @@ pub(crate) fn check_id_shape(id: &str, what: &str) -> Result<(), String> {
Ok(())
}

/// A bare host: no scheme, no path, no port, no wildcard. The service adds
/// HTTPS; the app never names a scheme, so it cannot ask for plain HTTP.
/// A bare host, optionally with one explicit port: no scheme, no path, no
/// wildcard. The service adds HTTPS; the app never names a scheme, so it
/// cannot ask for plain HTTP. The runtime gate already honors `host:port`
/// (a bare listed host matches any port; `host:port` matches only that
/// port) — admission accepting the same form keeps the listing's declared
/// granularity identical to what the runtime actually allows.
fn check_host(host: &str) -> Result<(), String> {
if host.is_empty() || host.len() > 253 {
return Err(format!("host {host:?} must be 1 to 253 characters"));
Expand All @@ -321,13 +325,26 @@ fn check_host(host: &str) -> Result<(), String> {
if host.contains('*') {
return Err(format!("host {host:?} may not use a wildcard"));
}
if host.contains(':') {
return Err(format!("host {host:?} may not name a port"));
}
if !host.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') {
let host_name = match host.rsplit_once(':') {
Some((name, port)) => {
if name.is_empty() {
return Err(format!("host {host:?} names no host before its port"));
}
if port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) {
return Err(format!("host {host:?} names a malformed port"));
}
match port.parse::<u32>() {
Ok(n) if (1..=65535).contains(&n) => {}
_ => return Err(format!("host {host:?} names a port out of range")),
}
name
}
None => host,
};
if !host_name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') {
return Err(format!("host {host:?} holds a character a host name may not"));
}
if host.starts_with('.') || host.ends_with('.') || host.contains("..") {
if host_name.starts_with('.') || host_name.ends_with('.') || host_name.contains("..") {
return Err(format!("host {host:?} is not a well-formed host name"));
}
Ok(())
Expand Down
38 changes: 35 additions & 3 deletions crates/app-contract/tests/growth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,9 +397,41 @@ fn a_newer_minor_ignores_unknown_fields_at_every_level() {
);
// What the build knows is read as written.
assert_eq!(manifest.network.hosts, ["api.weather.example"]);
assert_eq!(manifest.storage.max_bytes, Some(4096));
assert_eq!(manifest.research.as_ref().unwrap().langs, ["en"]);
assert_eq!(manifest.agent.as_ref().unwrap().model.as_ref().unwrap().per_task["triage"].tier, ModelTier::Fast);
assert_eq!(manifest.storage.max_bytes, Some(4096));}

/// The runtime gate has always honored `host:port` (a bare listed host matches
/// any port; `host:port` matches only that port) — admission accepts the same
/// form, so a listing's declared granularity is what the runtime allows.
/// (Issue #214.)
#[test]
fn network_hosts_may_name_one_explicit_port() {
let ok = parse(&manifest_with(r#""network":{"hosts":["api.weather.example:8443"]}"#))
.unwrap();
let policy = resolve(&ok, &HostLimits::default().with_require_signature(false)).unwrap();
assert!(policy.hosts.contains("api.weather.example:8443"));

// 裸 host 行为不变
let bare = parse(&manifest_with(r#""network":{"hosts":["api.weather.example"]}"#))
.unwrap();
resolve(&bare, &HostLimits::default().with_require_signature(false)).unwrap();

for bad in [
"api.weather.example:", // 空端口
"api.weather.example:abc", // 非数字
"api.weather.example:0", // 端口 0
"api.weather.example:65536", // 超界
":443", // 无主机名
"api.weather.example:44:3", // 多冒号
"api.weather.example:443/x", // 路径
"*.example.com:443", // 通配符
] {
let err = resolve(
&parse(&manifest_with(&format!(r#""network":{{"hosts":["{bad}"]}}"#))).unwrap(),
&HostLimits::default().with_require_signature(false),
)
.unwrap_err();
assert!(err.contains("host"), "unexpected error for {bad}: {err}");
}
}

#[test]
Expand Down
7 changes: 5 additions & 2 deletions docs/PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -831,8 +831,11 @@ Source: `RESERVED_NAMES` in `crates/app-contract/src/manifest.rs`.

`network.hosts` lists expected destinations for disclosure. The runtime
network module is available even without `net` or a host list. Listed hosts
still need a valid disclosure format: bare host names with no scheme, path,
port or wildcard. This format rule does not restrict runtime destinations.
need a valid disclosure format: bare host names with no scheme, path or
wildcard — optionally with one explicit port (`api.example.com:8443`).
A bare host matches that host on any port at runtime; a host with a port
matches only that port, so the listing's declared granularity is exactly
what the runtime allows.

```text
[refused] policy: host "https://api.open-meteo.com/v1" must be a bare host name, with no scheme or path
Expand Down
2 changes: 1 addition & 1 deletion docs/PUBLISHING.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,7 @@ ID 请以你能控制的反向域名前缀开头,例如把自己的域名倒

### 网络主机

`network.hosts` 披露预计访问的目的地。即使没有声明 `net` 或列出主机,运行时网络模块也可用。已列出的主机仍须符合披露格式:纯主机名,不带协议、路径、端口或通配符。这项格式规则不会限制运行时的网络目的地。
`network.hosts` 披露预计访问的目的地。即使没有声明 `net` 或列出主机,运行时网络模块也可用。已列出的主机须符合披露格式:纯主机名,不带协议、路径或通配符——可显式带一个端口(`api.example.com:8443`)。运行时里纯主机名匹配该主机的任意端口,带端口的主机只匹配该端口:披露粒度与运行时实际放行范围一致。

```text
[refused] policy: host "https://api.open-meteo.com/v1" must be a bare host name, with no scheme or path
Expand Down