OXYGENHUB

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 +
```