From dbdcfdb61afd93a37e825ddb37173aa1ff7dcc7b Mon Sep 17 00:00:00 2001 From: kkkkikun Date: Wed, 14 Oct 2026 08:02:22 +0800 Subject: [PATCH] policy: admit host:port entries in network.hosts (fixes #214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 准入层(app-contract check_host)此前拒绝带端口条目,而运行时门 (splash_policy.rs url_allowed)本就支持「裸 host 匹配任意端口、 host:port 只匹配该端口」——两者错位使 listing 声明的粒度与实际放行 范围不一致,商店也无法表达端口约束。 本 PR 让准入接受 host:port 形态:端口须为纯数字且落在 1..=65535; 空端口/非数字/超界/无主机名/多冒号/带路径/通配符逐一拒绝(新增 growth.rs 测试覆盖)。裸 host 行为不变。PUBLISHING 双语档同步。 Tested: cargo test -p octosense-app-contract 全绿(61 项,含新增 network_hosts_may_name_one_explicit_port 九例)。 Found while building a GOSIM hackathon script app that needed to declare one local service port. --- crates/app-contract/src/policy.rs | 31 +++++++++++++++++------ crates/app-contract/tests/growth.rs | 38 ++++++++++++++++++++++++++--- docs/PUBLISHING.md | 7 ++++-- docs/PUBLISHING.zh-CN.md | 2 +- 4 files changed, 65 insertions(+), 13 deletions(-) diff --git a/crates/app-contract/src/policy.rs b/crates/app-contract/src/policy.rs index 3a1d321..444a779 100644 --- a/crates/app-contract/src/policy.rs +++ b/crates/app-contract/src/policy.rs @@ -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")); @@ -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::() { + 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(()) diff --git a/crates/app-contract/tests/growth.rs b/crates/app-contract/tests/growth.rs index 61dad6c..7f88799 100644 --- a/crates/app-contract/tests/growth.rs +++ b/crates/app-contract/tests/growth.rs @@ -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] diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 3fafc7b..0bf2224 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -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 diff --git a/docs/PUBLISHING.zh-CN.md b/docs/PUBLISHING.zh-CN.md index 5d80231..6e6688f 100644 --- a/docs/PUBLISHING.zh-CN.md +++ b/docs/PUBLISHING.zh-CN.md @@ -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