Outbound requests only to public addresses 9a777fec4ed9
Webhooks and AI assistants (calls and model lists) now go through net::client: the host is resolved and refused if any address it resolves to is loopback, private, shared (CGNAT), link-local (the cloud metadata address among them), multicast, an IPv6 local range or an IPv4-mapped/NAT64 form of those; the connection is pinned to the checked addresses (no DNS rebinding) and redirects are not followed. A refused webhook delivery records 'not sent' with the reason. A site administrator may allow a local service on purpose with [outbound] allow in hub.toml (e.g. a local Ollama); repositories cannot. Closes #219
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-15 23:14 UTC
- Signature
- ✔ signed by Claude Code
- Hash
9a777fec4ed9b8ffdd115cbfc4537d1b0caa63a602c7fe17eef7d99ac601ca27- Depends on
- 155cab142b63 18e381ea88c2 19bb31ff1f5c 3f4b0ea6eabf 42fa95540468 5360cea36c30 62ceb2eec18c 93be9ce4653e 9756c67d3aec 9a8e3b4419e0 b64e2537d702 d23464c0ac60 d7d182467382 de505df5cf8d e71846aa07b9
- Depended on by
- aeed63c00ad7 4f40eed0588f 5436f0dd9429 3b1bc27fe805 d74ce22fa80e 32cfb089f5e6 aa0bfbd11f02
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 21 atoms, 1 tree operation, 10 files
Browse the tree at this change
added crates/hub/src/net.rs +151 −0
| 1 | + | //! Outbound requests to addresses people give (oxygenhub#219): webhooks |
|
| 2 | + | //! and AI assistants. The hub only calls public addresses: the host is |
|
| 3 | + | //! resolved, every address it resolves to must be public, and the |
|
| 4 | + | //! connection is pinned to those addresses (so a second lookup cannot |
|
| 5 | + | //! point it elsewhere); redirects are not followed. A site administrator |
|
| 6 | + | //! may allow a private address on purpose (`[outbound] allow` in |
|
| 7 | + | //! hub.toml), such as a local Ollama. |
|
| 8 | + | ||
| 9 | + | use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; |
|
| 10 | + | use std::time::Duration; |
|
| 11 | + | ||
| 12 | + | /// Whether `ip` is an address on the public internet. |
|
| 13 | + | pub fn public(ip: IpAddr) -> bool { |
|
| 14 | + | match ip { |
|
| 15 | + | IpAddr::V4(v4) => public_v4(v4), |
|
| 16 | + | IpAddr::V6(v6) => public_v6(v6), |
|
| 17 | + | } |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | fn public_v4(ip: Ipv4Addr) -> bool { |
|
| 21 | + | let [a, b, c, _] = ip.octets(); |
|
| 22 | + | !(ip.is_loopback() |
|
| 23 | + | || ip.is_private() |
|
| 24 | + | || ip.is_link_local() // 169.254/16, the cloud metadata address among them |
|
| 25 | + | || ip.is_multicast() |
|
| 26 | + | || ip.is_unspecified() |
|
| 27 | + | || ip.is_broadcast() |
|
| 28 | + | || ip.is_documentation() |
|
| 29 | + | || a == 0 |
|
| 30 | + | || (a == 100 && (64..128).contains(&b)) // shared address space (CGNAT) |
|
| 31 | + | || (a == 192 && b == 0 && c == 0) |
|
| 32 | + | || (a == 198 && (b == 18 || b == 19)) // benchmarking |
|
| 33 | + | || a >= 240) |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | fn public_v6(ip: Ipv6Addr) -> bool { |
|
| 37 | + | if let Some(v4) = ip.to_ipv4_mapped() { |
|
| 38 | + | return public_v4(v4); |
|
| 39 | + | } |
|
| 40 | + | let s = ip.segments(); |
|
| 41 | + | // NAT64 (64:ff9b::/96) carries an IPv4 address: judge that. |
|
| 42 | + | if s[0] == 0x64 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] { |
|
| 43 | + | return public_v4(Ipv4Addr::new((s[6] >> 8) as u8, s[6] as u8, (s[7] >> 8) as u8, s[7] as u8)); |
|
| 44 | + | } |
|
| 45 | + | !(ip.is_loopback() |
|
| 46 | + | || ip.is_unspecified() |
|
| 47 | + | || ip.is_multicast() |
|
| 48 | + | || (s[0] & 0xfe00) == 0xfc00 // unique local |
|
| 49 | + | || (s[0] & 0xffc0) == 0xfe80 // link local |
|
| 50 | + | || (s[0] == 0x2001 && s[1] == 0x0db8)) // documentation |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | /// Whether `url` is one a site administrator allowed: an `allow` entry |
|
| 54 | + | /// `scheme://host` matches any port, `scheme://host:port` that port. |
|
| 55 | + | pub fn allowed(allow: &[String], url: &reqwest::Url) -> bool { |
|
| 56 | + | allow.iter().filter_map(|a| reqwest::Url::parse(a).ok()).any(|a| { |
|
| 57 | + | a.scheme() == url.scheme() |
|
| 58 | + | && a.host_str().map(str::to_ascii_lowercase) == url.host_str().map(str::to_ascii_lowercase) |
|
| 59 | + | && (a.port().is_none() || a.port_or_known_default() == url.port_or_known_default()) |
|
| 60 | + | }) |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | /// A client for one request to `url`: refused, with a plain reason, when |
|
| 64 | + | /// the address is not public and not allowed. |
|
| 65 | + | pub async fn client(url: &str, allow: &[String], timeout: Duration) -> Result<reqwest::Client, String> { |
|
| 66 | + | let parsed = reqwest::Url::parse(url).map_err(|_| format!("{url} is not an address"))?; |
|
| 67 | + | if !matches!(parsed.scheme(), "http" | "https") { |
|
| 68 | + | return Err("only http and https addresses are called".into()); |
|
| 69 | + | } |
|
| 70 | + | let host = parsed.host_str().ok_or("the address has no host")?.trim_start_matches('[').trim_end_matches(']').to_string(); |
|
| 71 | + | let port = parsed.port_or_known_default().unwrap_or(443); |
|
| 72 | + | let builder = reqwest::Client::builder() |
|
| 73 | + | .timeout(timeout) |
|
| 74 | + | .redirect(reqwest::redirect::Policy::none()) |
|
| 75 | + | .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION"))); |
|
| 76 | + | if allowed(allow, &parsed) { |
|
| 77 | + | return builder.build().map_err(|e| e.to_string()); |
|
| 78 | + | } |
|
| 79 | + | let addrs: Vec<SocketAddr> = match host.parse::<IpAddr>() { |
|
| 80 | + | Ok(ip) => vec![SocketAddr::new(ip, port)], |
|
| 81 | + | Err(_) => tokio::net::lookup_host((host.as_str(), port)).await.map_err(|_| format!("{host} could not be found"))?.collect(), |
|
| 82 | + | }; |
|
| 83 | + | if addrs.is_empty() { |
|
| 84 | + | return Err(format!("{host} has no address")); |
|
| 85 | + | } |
|
| 86 | + | if let Some(bad) = addrs.iter().find(|a| !public(a.ip())) { |
|
| 87 | + | return Err(format!( |
|
| 88 | + | "{host} is {}, a private or local address; the hub only calls public addresses (a site administrator can allow one with [outbound] allow in hub.toml)", |
|
| 89 | + | bad.ip() |
|
| 90 | + | )); |
|
| 91 | + | } |
|
| 92 | + | builder.resolve_to_addrs(&host, &addrs).build().map_err(|e| e.to_string()) |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | #[cfg(test)] |
|
| 96 | + | mod tests { |
|
| 97 | + | use super::*; |
|
| 98 | + | ||
| 99 | + | #[test] |
|
| 100 | + | fn only_public_addresses_are_public() { |
|
| 101 | + | for bad in [ |
|
| 102 | + | "127.0.0.1", |
|
| 103 | + | "10.1.2.3", |
|
| 104 | + | "172.16.0.1", |
|
| 105 | + | "192.168.1.1", |
|
| 106 | + | "169.254.169.254", |
|
| 107 | + | "100.64.0.1", |
|
| 108 | + | "0.0.0.0", |
|
| 109 | + | "224.0.0.1", |
|
| 110 | + | "255.255.255.255", |
|
| 111 | + | "::1", |
|
| 112 | + | "::", |
|
| 113 | + | "fc00::1", |
|
| 114 | + | "fd12:3456::1", |
|
| 115 | + | "fe80::1", |
|
| 116 | + | "::ffff:127.0.0.1", |
|
| 117 | + | "::ffff:10.0.0.1", |
|
| 118 | + | "64:ff9b::a9fe:a9fe", |
|
| 119 | + | "2001:db8::1", |
|
| 120 | + | ] { |
|
| 121 | + | assert!(!public(bad.parse().unwrap()), "{bad} is not public"); |
|
| 122 | + | } |
|
| 123 | + | for good in ["1.1.1.1", "8.8.8.8", "104.21.68.34", "2606:4700:4700::1111", "::ffff:8.8.8.8"] { |
|
| 124 | + | assert!(public(good.parse().unwrap()), "{good} is public"); |
|
| 125 | + | } |
|
| 126 | + | } |
|
| 127 | + | ||
| 128 | + | #[test] |
|
| 129 | + | fn an_allow_entry_matches_its_host_and_port() { |
|
| 130 | + | let allow = vec!["http://127.0.0.1:11434".to_string(), "http://ollama.lan".to_string()]; |
|
| 131 | + | let ok = |u: &str| allowed(&allow, &reqwest::Url::parse(u).unwrap()); |
|
| 132 | + | assert!(ok("http://127.0.0.1:11434/v1/chat/completions") && ok("http://ollama.lan:8080/x")); |
|
| 133 | + | assert!(!ok("http://127.0.0.1:6379/") && !ok("https://127.0.0.1:11434/") && !ok("http://other.lan/")); |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | #[tokio::test] |
|
| 137 | + | async fn private_addresses_are_refused_with_a_reason() { |
|
| 138 | + | for u in [ |
|
| 139 | + | "http://127.0.0.1:9/", |
|
| 140 | + | "http://169.254.169.254/latest/meta-data/", |
|
| 141 | + | "http://[::1]/", |
|
| 142 | + | "http://10.0.0.5/hook", |
|
| 143 | + | "http://localhost:8080/", |
|
| 144 | + | ] { |
|
| 145 | + | let e = client(u, &[], Duration::from_secs(1)).await.unwrap_err(); |
|
| 146 | + | assert!(e.contains("private or local address"), "{u}: {e}"); |
|
| 147 | + | } |
|
| 148 | + | assert!(client("ftp://example.org/", &[], Duration::from_secs(1)).await.is_err()); |
|
| 149 | + | assert!(client("http://127.0.0.1:11434/", &["http://127.0.0.1:11434".into()], Duration::from_secs(1)).await.is_ok()); |
|
| 150 | + | } |
|
| 151 | + | } |
modified crates/hub/src/assistants.rs +3 −11
| … | 442 unchanged lines |
||
| 443 | 443 | let Some(token) = app.assistant_key.open(&a.token) else { |
|
| 444 | 444 | return Err("its token cannot be read: set it again".into()); |
|
| 445 | 445 | }; |
|
| 446 | - | let client = reqwest::Client::builder() |
|
| 447 | - | .timeout(wait) |
|
| 448 | - | .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION"))) |
|
| 449 | - | .build() |
|
| 450 | - | .map_err(|e| e.to_string())?; |
|
| 446 | + | let client = crate::net::client(&a.endpoint, &app.config.outbound.allow, wait).await?; |
|
| 451 | 447 | let mut req = client.post(&a.endpoint).header("content-type", "application/json"); |
|
| 452 | 448 | let body = match a.api.as_str() { |
|
| 453 | 449 | "anthropic-messages" => { |
|
| … | 138 unchanged lines |
||
| 592 | 588 | /// The chat models an endpoint offers for this token, sorted; `Err` when |
|
| 593 | 589 | /// it cannot say (no list, refused, unreachable). Embedding, speech and |
|
| 594 | 590 | /// image models are left out. |
|
| 595 | - | pub async fn list_models(endpoint: &str, api: &str, token: &str) -> Result<Vec<String>, String> { |
|
| 591 | + | pub async fn list_models(endpoint: &str, api: &str, token: &str, allow: &[String]) -> Result<Vec<String>, String> { |
|
| 596 | 592 | let url = models_url(endpoint).ok_or("this endpoint has no model list next to it")?; |
|
| 597 | - | let client = reqwest::Client::builder() |
|
| 598 | - | .timeout(std::time::Duration::from_secs(15)) |
|
| 599 | - | .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION"))) |
|
| 600 | - | .build() |
|
| 601 | - | .map_err(|e| e.to_string())?; |
|
| 593 | + | let client = crate::net::client(&url, allow, std::time::Duration::from_secs(15)).await?; |
|
| 602 | 594 | let mut req = client.get(&url); |
|
| 603 | 595 | if api == "anthropic-messages" { |
|
| 604 | 596 | req = req.header("anthropic-version", "2023-06-01"); |
|
| … | 446 unchanged lines |
modified crates/hub/src/config.rs +12 −0
| … | 22 unchanged lines |
||
| 23 | 23 | pub apt: Apt, |
|
| 24 | 24 | pub diffusion: Diffusion, |
|
| 25 | 25 | pub backups: Backups, |
|
| 26 | + | pub outbound: Outbound, |
|
| 26 | 27 | } |
|
| 27 | 28 | ||
| 29 | + | /// Outbound requests to addresses people give (oxygenhub#219): private |
|
| 30 | + | /// and local addresses are refused unless listed here, as `scheme://host` |
|
| 31 | + | /// (any port) or `scheme://host:port`, e.g. a local Ollama: |
|
| 32 | + | /// `allow = ["http://127.0.0.1:11434"]`. |
|
| 33 | + | #[derive(Clone, Debug, Default, Deserialize)] |
|
| 34 | + | #[serde(default, deny_unknown_fields)] |
|
| 35 | + | pub struct Outbound { |
|
| 36 | + | pub allow: Vec<String>, |
|
| 37 | + | } |
|
| 38 | + | ||
| 28 | 39 | /// Backups (docs/backups.md, oxygenhub#169): the directories on this host |
|
| 29 | 40 | /// an owner may back up to. Empty, only GCS destinations can be chosen. |
|
| 30 | 41 | #[derive(Clone, Debug, Deserialize)] |
|
| … | 265 unchanged lines |
||
| 296 | 307 | apt: Apt::default(), |
|
| 297 | 308 | diffusion: Diffusion::default(), |
|
| 298 | 309 | backups: Backups::default(), |
|
| 310 | + | outbound: Outbound::default(), |
|
| 299 | 311 | } |
|
| 300 | 312 | } |
|
| 301 | 313 | } |
|
| … | 123 unchanged lines |
modified crates/hub/src/hooks.rs +8 −5
| … | 173 unchanged lines |
||
| 174 | 174 | Ok(d) => d, |
|
| 175 | 175 | Err(_) => return 0, |
|
| 176 | 176 | }; |
|
| 177 | - | let client = reqwest::Client::builder() |
|
| 178 | - | .timeout(std::time::Duration::from_secs(20)) |
|
| 179 | - | .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION"))) |
|
| 180 | - | .build(); |
|
| 181 | - | let Ok(client) = client else { return 0 }; |
|
| 182 | 177 | let mut n = 0; |
|
| 183 | 178 | for d in due { |
|
| 184 | 179 | let Ok(Some(hook)) = hdb::hook(&app.db, d.hook_id).await else { continue }; |
|
| 185 | 180 | n += 1; |
|
| 181 | + | // Only public addresses, pinned, no redirects (#219). |
|
| 182 | + | let client = match crate::net::client(&hook.url, &app.config.outbound.allow, std::time::Duration::from_secs(20)).await { |
|
| 183 | + | Ok(c) => c, |
|
| 184 | + | Err(why) => { |
|
| 185 | + | hdb::record_attempt(&app.db, d.id, d.attempts, None, &format!("not sent: {why}")).await.ok(); |
|
| 186 | + | continue; |
|
| 187 | + | } |
|
| 188 | + | }; |
|
| 186 | 189 | let body = body_for(&hook.format, &d.payload, app.config.base_url()); |
|
| 187 | 190 | let (status, response) = match client |
|
| 188 | 191 | .post(&hook.url) |
|
| … | 113 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 22 unchanged lines |
||
| 23 | 23 | pub mod mail; |
|
| 24 | 24 | pub mod maintenance; |
|
| 25 | 25 | pub mod mirror; |
|
| 26 | + | pub mod net; |
|
| 26 | 27 | pub mod notify; |
|
| 27 | 28 | pub mod oidc; |
|
| 28 | 29 | pub mod protocol; |
|
| … | 273 unchanged lines |
modified crates/hub/src/web/assistants.rs +6 −6
| … | 98 unchanged lines |
||
| 99 | 99 | ||
| 100 | 100 | /// When the endpoint lists its models, the model must be one of them |
|
| 101 | 101 | /// (#109); `Err` names a few it does offer. |
|
| 102 | - | async fn model_offered(fields: &Fields, token: &str) -> Result<(), String> { |
|
| 103 | - | match assistants::list_models(&fields.endpoint, &fields.api, token).await { |
|
| 102 | + | async fn model_offered(fields: &Fields, token: &str, allow: &[String]) -> Result<(), String> { |
|
| 103 | + | match assistants::list_models(&fields.endpoint, &fields.api, token, allow).await { |
|
| 104 | 104 | Ok(models) if !models.iter().any(|m| m == &fields.model) => { |
|
| 105 | 105 | let close: Vec<&String> = { |
|
| 106 | 106 | let stem = fields.model.split(['-', '.']).next().unwrap_or("").to_lowercase(); |
|
| … | 76 unchanged lines |
||
| 183 | 183 | Ok(x) => x, |
|
| 184 | 184 | Err(e) => return Ok(Err(e)), |
|
| 185 | 185 | }; |
|
| 186 | - | if let Err(e) = model_offered(&fields, f.token.trim()).await { |
|
| 186 | + | if let Err(e) = model_offered(&fields, f.token.trim(), &app.config.outbound.allow).await { |
|
| 187 | 187 | return Ok(Err(e)); |
|
| 188 | 188 | } |
|
| 189 | 189 | let token = app.assistant_key.seal(f.token.trim()); |
|
| … | 19 unchanged lines |
||
| 209 | 209 | }; |
|
| 210 | 210 | let token = f.token.trim(); |
|
| 211 | 211 | let stored = if token.is_empty() { app.assistant_key.open(&a.token).unwrap_or_default() } else { token.to_string() }; |
|
| 212 | - | if let Err(e) = model_offered(&fields, &stored).await { |
|
| 212 | + | if let Err(e) = model_offered(&fields, &stored, &app.config.outbound.allow).await { |
|
| 213 | 213 | return Ok(Err(e)); |
|
| 214 | 214 | } |
|
| 215 | 215 | let sealed = (!token.is_empty()).then(|| app.assistant_key.seal(token)); |
|
| … | 80 unchanged lines |
||
| 296 | 296 | } |
|
| 297 | 297 | } |
|
| 298 | 298 | } |
|
| 299 | - | Ok(match assistants::list_models(&endpoint, &api, &token).await { |
|
| 299 | + | Ok(match assistants::list_models(&endpoint, &api, &token, &app.config.outbound.allow).await { |
|
| 300 | 300 | Ok(m) => serde_json::json!({ "models": m }), |
|
| 301 | 301 | Err(e) => serde_json::json!({ "error": e }), |
|
| 302 | 302 | }) |
|
| … | 52 unchanged lines |
||
| 355 | 355 | (Some(a), true) => app.assistant_key.open(&a.token).unwrap_or_default(), |
|
| 356 | 356 | _ => token.clone(), |
|
| 357 | 357 | }; |
|
| 358 | - | if let Err(e) = model_offered(&fields, &effective).await { |
|
| 358 | + | if let Err(e) = model_offered(&fields, &effective, &app.config.outbound.allow).await { |
|
| 359 | 359 | return Ok(Err(e)); |
|
| 360 | 360 | } |
|
| 361 | 361 | Ok(Ok(match existing { |
|
| … | 41 unchanged lines |
modified crates/hub/tests/accounts.rs +2 −0
| … | 39 unchanged lines |
||
| 40 | 40 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 41 | 41 | let port = listener.local_addr().unwrap().port(); |
|
| 42 | 42 | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 43 | + | // The stand-in webhook and assistant servers listen on 127.0.0.1 (#219). |
|
| 44 | + | config.outbound.allow = vec!["http://127.0.0.1".into()]; |
|
| 43 | 45 | config.registration.mode = "invite".into(); |
|
| 44 | 46 | let app = oxygenhub::build(config).await.unwrap(); |
|
| 45 | 47 | let router = oxygenhub::router(app.clone()); |
|
| … | 1198 unchanged lines |
modified crates/hub/tests/api.rs +2 −0
| … | 46 unchanged lines |
||
| 47 | 47 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 48 | 48 | let port = listener.local_addr().unwrap().port(); |
|
| 49 | 49 | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 50 | + | // The stand-in webhook and assistant servers listen on 127.0.0.1 (#219). |
|
| 51 | + | config.outbound.allow = vec!["http://127.0.0.1".into()]; |
|
| 50 | 52 | let app = oxygenhub::build(config).await.unwrap(); |
|
| 51 | 53 | let router = oxygenhub::router(app.clone()); |
|
| 52 | 54 | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| … | 1421 unchanged lines |
modified crates/hub/tests/proposals.rs +2 −0
| … | 43 unchanged lines |
||
| 44 | 44 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 45 | 45 | let port = listener.local_addr().unwrap().port(); |
|
| 46 | 46 | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 47 | + | // The stand-in webhook and assistant servers listen on 127.0.0.1 (#219). |
|
| 48 | + | config.outbound.allow = vec!["http://127.0.0.1".into()]; |
|
| 47 | 49 | let app = oxygenhub::build(config).await.unwrap(); |
|
| 48 | 50 | let router = oxygenhub::router(app.clone()); |
|
| 49 | 51 | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| … | 674 unchanged lines |
modified docs/deploy.md +17 −0
| … | 48 unchanged lines |
||
| 49 | 49 | is on (2026-10-15). Strict-Transport-Security is not sent yet: it waits |
|
| 50 | 50 | for the security headers (#222) and the site owner's decision, because |
|
| 51 | 51 | browsers remember it for months. |
|
| 52 | + | ||
| 53 | + | ## Outbound requests (oxygenhub#219) |
|
| 54 | + | ||
| 55 | + | Webhooks and AI assistants call addresses people give, so the hub only |
|
| 56 | + | calls **public** addresses: it resolves the host, refuses if any address |
|
| 57 | + | it resolves to is loopback, private, shared (CGNAT), link-local (the |
|
| 58 | + | cloud metadata address among them), multicast or an IPv6 local range, |
|
| 59 | + | pins the connection to the addresses it checked, and follows no |
|
| 60 | + | redirects. A refused webhook delivery records "not sent: …" with the |
|
| 61 | + | reason; an assistant says why it cannot be reached. To use a service on |
|
| 62 | + | your own network on purpose (a local Ollama), a site administrator lists |
|
| 63 | + | it in `hub.toml`: |
|
| 64 | + | ||
| 65 | + | ```toml |
|
| 66 | + | [outbound] |
|
| 67 | + | allow = ["http://127.0.0.1:11434"] # scheme://host (any port) or scheme://host:port |
|
| 68 | + | ``` |