9a777fec Andy McCall 20 days ago ↶
1
2
3
4
5
6
7
8
9
use std:: net:: { IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr} ;
10
use std:: time:: Duration;
11
12
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 ( )
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) )
31
| | ( a = = 192 & & b = = 0 & & c = = 0 )
32
| | ( a = = 198 & & ( b = = 18 | | b = = 19 ) )
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
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
49
| | ( s[ 0 ] & 0xffc0 ) = = 0xfe80
50
| | ( s[ 0 ] = = 0x2001 & & s[ 1 ] = = 0x0db8 ) )
51
}
52
53
54
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
64
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
}
32cfb089 Andy McCall 20 days ago ↶
79
80
81
let lower = host.to_ascii_lowercase ( ) ;
82
if lower = = " localhost" | | lower.ends_with ( " .localhost" ) {
83
return Err ( format! (
84
" {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)"
85
) ) ;
86
}
9a777fec Andy McCall 20 days ago ↶
87
let addrs: Vec< SocketAddr> = match host.parse:: < IpAddr> ( ) {
88
Ok ( ip) => vec! [ SocketAddr:: new( ip, port) ] ,
89
Err ( _ ) => tokio:: net:: lookup_host( ( host.as_str ( ) , port) ) .await.map_err ( | _| format! ( " {host} could not be found" ) ) ? .collect ( ) ,
90
} ;
91
if addrs.is_empty ( ) {
92
return Err ( format! ( " {host} has no address" ) ) ;
93
}
94
if let Some ( bad) = addrs.iter ( ) .find ( | a | ! public ( a.ip ( ) ) ) {
95
return Err ( format! (
96
" {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)" ,
97
bad.ip ( )
98
) ) ;
99
}
100
builder.resolve_to_addrs ( & host, & addrs) .build ( ) .map_err ( | e | e.to_string ( ) )
101
}
102
103
# [ cfg ( test ) ]
104
mod tests {
105
use super :: * ;
106
107
# [ test ]
108
fn only_public_addresses_are_public ( ) {
109
for bad in [
110
" 127.0.0.1" ,
111
" 10.1.2.3" ,
112
" 172.16.0.1" ,
113
" 192.168.1.1" ,
114
" 169.254.169.254" ,
115
" 100.64.0.1" ,
116
" 0.0.0.0" ,
117
" 224.0.0.1" ,
118
" 255.255.255.255" ,
119
" ::1" ,
120
" ::" ,
121
" fc00::1" ,
122
" fd12:3456::1" ,
123
" fe80::1" ,
124
" ::ffff:127.0.0.1" ,
125
" ::ffff:10.0.0.1" ,
126
" 64:ff9b::a9fe:a9fe" ,
127
" 2001:db8::1" ,
128
] {
129
assert! ( ! public ( bad.parse ( ) .unwrap ( ) ) , " {bad} is not public" ) ;
130
}
131
for good in [ " 1.1.1.1" , " 8.8.8.8" , " 104.21.68.34" , " 2606:4700:4700::1111" , " ::ffff:8.8.8.8" ] {
132
assert! ( public ( good.parse ( ) .unwrap ( ) ) , " {good} is public" ) ;
133
}
134
}
135
136
# [ test ]
137
fn an_allow_entry_matches_its_host_and_port ( ) {
138
let allow = vec! [ " http://127.0.0.1:11434" .to_string ( ) , " http://ollama.lan" .to_string ( ) ] ;
139
let ok = | u : & str | allowed ( & allow, & reqwest:: Url:: parse( u) .unwrap ( ) ) ;
140
assert! ( ok ( " http://127.0.0.1:11434/v1/chat/completions" ) & & ok ( " http://ollama.lan:8080/x" ) ) ;
141
assert! ( ! ok ( " http://127.0.0.1:6379/" ) & & ! ok ( " https://127.0.0.1:11434/" ) & & ! ok ( " http://other.lan/" ) ) ;
142
}
143
144
# [ tokio ::test ]
145
async fn private_addresses_are_refused_with_a_reason ( ) {
146
for u in [
147
" http://127.0.0.1:9/" ,
148
" http://169.254.169.254/latest/meta-data/" ,
149
" http://[::1]/" ,
150
" http://10.0.0.5/hook" ,
151
" http://localhost:8080/" ,
32cfb089 Andy McCall 20 days ago ↶
152
" http://app.LOCALHOST/" ,
9a777fec Andy McCall 20 days ago ↶
153
] {
154
let e = client ( u, & [ ] , Duration:: from_secs( 1 ) ) .await.unwrap_err ( ) ;
155
assert! ( e.contains ( " private or local address" ) , " {u}: {e}" ) ;
156
}
157
assert! ( client ( " ftp://example.org/" , & [ ] , Duration:: from_secs( 1 ) ) .await.is_err ( ) ) ;
158
assert! ( client ( " http://127.0.0.1:11434/" , & [ " http://127.0.0.1:11434" .into ( ) ] , Duration:: from_secs( 1 ) ) .await.is_ok ( ) ) ;
159
}
160
}