- 1
//! Bounded outbound HTTP GET tool (docs/design/29-personal-os.md phase P4). - 2
//! - 3
//! Permission posture (Ask in restricted modes, Allow under FullAccess or an - 4
//! explicit rule) is applied by the registry/permission engine downstream; - 5
//! this tool performs no permission checks itself. - 6
//! - 7
//! Credentials are never sent: bare requests, no cookie store, no auth - 8
//! headers, ever. - 9
- 10
use std::error::Error as StdError; - 11
use std::fmt; - 12
use std::net::{IpAddr, Ipv4Addr, ToSocketAddrs}; - 13
use std::time::Duration; - 14
- 15
use async_trait::async_trait; - 16
use serde_json::Value; - 17
- 18
use crate::{ResourceClaims, Tool, ToolContext, ToolOutput}; - 19
- 20
const TOTAL_TIMEOUT_SECS: u64 = 15; - 21
const TOTAL_TIMEOUT: Duration = Duration::from_secs(TOTAL_TIMEOUT_SECS); - 22
const MAX_BODY_BYTES: usize = 512 * 1024; - 23
const MAX_REDIRECTS: usize = 3; - 24
- 25
/// Arbitrary port: `ToSocketAddrs` needs one, but only the resolved IP is - 26
/// screened; no connection is made on it. - 27
const SCREEN_PORT: u16 = 80; - 28
- 29
pub struct WebFetchTool; - 30
- 31
/// Marker threaded into a redirect-policy rejection so the typed block reason - 32
/// survives inside the opaque `reqwest::Error` source chain. - 33
#[derive(Debug)] - 34
struct BlockedRange(String); - 35
- 36
impl fmt::Display for BlockedRange { - 37
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - 38
write!(f, "{}", self.0) - 39
} - 40
} - 41
- 42
impl StdError for BlockedRange {} - 43
- 44
fn blocked_message(class: &str) -> String { - 45
format!("blocked: {class} address range") - 46
} - 47
- 48
fn classify_v4(ip: Ipv4Addr) -> Option<&'static str> { - 49
if ip.is_loopback() { - 50
Some("loopback") - 51
} else if ip.is_unspecified() { - 52
Some("unspecified") - 53
} else if ip.is_private() { - 54
Some("private RFC1918") - 55
} else if ip.is_link_local() { - 56
Some("link-local") - 57
} else { - 58
None - 59
} - 60
} - 61
- 62
fn classify(ip: IpAddr) -> Option<&'static str> { - 63
match ip { - 64
IpAddr::V4(v4) => classify_v4(v4), - 65
IpAddr::V6(v6) => { - 66
if let Some(mapped) = v6.to_ipv4_mapped() { - 67
classify_v4(mapped) - 68
} else if v6.is_loopback() { - 69
Some("loopback") - 70
} else if v6.is_unspecified() { - 71
Some("unspecified") - 72
} else if v6.is_unique_local() { - 73
Some("unique-local") - 74
} else if v6.is_unicast_link_local() { - 75
Some("link-local") - 76
} else { - 77
None - 78
} - 79
} - 80
} - 81
} - 82
- 83
/// Pre-connect SSRF screen: resolves `host` and fails closed if ANY resolved - 84
/// address falls in a blocked range. v1 acknowledges the TOCTOU window of - 85
/// resolving here and letting the client resolve again (DNS rebinding); - 86
/// mitigation roadmap is single-use-resolve-then-connect pinning. - 87
pub(crate) fn ssrf_guard(host: &str) -> Result<(), GuardRejection> { - 88
// URL serialization brackets IPv6 literals ("[::1]"); ToSocketAddrs - 89
// wants the bare address. - 90
let bare = host - 91
.strip_prefix('[') - 92
.and_then(|rest| rest.strip_suffix(']')) - 93
.unwrap_or(host); - 94
let addrs = (bare, SCREEN_PORT).to_socket_addrs().map_err(|e| { - 95
GuardRejection::Unresolvable(format!("dns resolution failed for {bare}: {e}")) - 96
})?; - 97
for addr in addrs { - 98
if let Some(class) = classify(addr.ip()) { - 99
return Err(GuardRejection::Protected(class)); - 100
} - 101
} - 102
Ok(()) - 103
} - 104
- 105
/// Why the SSRF screen refused a host; every variant renders as a typed, - 106
/// self-explanatory tool error. - 107
#[derive(Debug)] - 108
pub(crate) enum GuardRejection { - 109
/// The lookup itself failed (fail closed); carries the rendered reason. - 110
Unresolvable(String), - 111
/// At least one resolved address fell in a protected range. - 112
Protected(&'static str), - 113
} - 114
- 115
impl fmt::Display for GuardRejection { - 116
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - 117
match self { - 118
Self::Unresolvable(detail) => write!(f, "{detail}"), - 119
Self::Protected(class) => write!(f, "{}", blocked_message(class)), - 120
} - 121
} - 122
} - 123
- 124
fn parse_target(raw: &str) -> Result<reqwest::Url, String> { - 125
let url = reqwest::Url::parse(raw).map_err(|e| format!("invalid url: {e}"))?; - 126
match url.scheme() { - 127
"http" | "https" => {} - 128
other => return Err(format!("unsupported scheme \"{other}\" (only http/https)")), - 129
} - 130
if url.host_str().is_none_or(str::is_empty) { - 131
return Err("invalid url: missing host".to_string()); - 132
} - 133
Ok(url) - 134
} - 135
- 136
fn follow_redirect(previous_count: usize) -> bool { - 137
previous_count < MAX_REDIRECTS - 138
} - 139
- 140
fn redirect_policy() -> reqwest::redirect::Policy { - 141
reqwest::redirect::Policy::custom(|attempt| { - 142
if !follow_redirect(attempt.previous().len()) { - 143
return attempt.error("too many redirects"); - 144
} - 145
match attempt.url().host_str().map(ssrf_guard) { - 146
Some(Ok(())) => attempt.follow(), - 147
Some(Err(rejection)) => attempt.error(BlockedRange(rejection.to_string())), - 148
None => attempt.error(BlockedRange("blocked: missing-host".to_string())), - 149
} - 150
}) - 151
} - 152
- 153
fn content_type_allowed(content_type: &str) -> bool { - 154
let mime = content_type - 155
.split(';') - 156
.next() - 157
.unwrap_or_default() - 158
.trim() - 159
.to_ascii_lowercase(); - 160
mime == "application/json" - 161
|| mime == "application/xhtml+xml" - 162
|| mime == "application/xml" - 163
|| mime.ends_with("+xml") - 164
|| mime.starts_with("text/") - 165
} - 166
- 167
fn find_blocked<'a>(err: &'a (dyn StdError + 'static)) -> Option<&'a BlockedRange> { - 168
let mut current: Option<&(dyn StdError + 'static)> = Some(err); - 169
while let Some(e) = current { - 170
if let Some(blocked) = e.downcast_ref::<BlockedRange>() { - 171
return Some(blocked); - 172
} - 173
current = e.source(); - 174
} - 175
None - 176
} - 177
- 178
fn request_error_message(e: &reqwest::Error) -> String { - 179
if let Some(blocked) = find_blocked(e) { - 180
return blocked.to_string(); - 181
} - 182
if e.is_redirect() { - 183
return format!("too many redirects (limit {MAX_REDIRECTS})"); - 184
} - 185
if e.is_timeout() { - 186
return format!("request exceeded its {TOTAL_TIMEOUT_SECS}s total timeout"); - 187
} - 188
format!("request failed: {e}") - 189
} - 190
- 191
#[async_trait] - 192
impl Tool for WebFetchTool { - 193
fn name(&self) -> &str { - 194
"webfetch" - 195
} - 196
- 197
fn serves(&self) -> &'static [&'static str] { - 198
&["web", "live-data"] - 199
} - 200
- 201
fn description(&self) -> &str { - 202
"Fetch a known URL over HTTP(S) with GET; this does not search the web or turn a search-results page into reliable facts. To locate current sources, discover an available search tool first. Blocks loopback/private/link-local targets, follows at most 3 redirects, caps the body at 512KiB, accepts only text/json/xml content types, and returns a status header line followed by the UTF-8 body. Never sends credentials." - 203
} - 204
- 205
fn schema(&self) -> Value { - 206
serde_json::json!({ - 207
"type": "object", - 208
"properties": { - 209
"url": {"type": "string", "description": "Absolute http(s) URL to fetch"} - 210
}, - 211
"required": ["url"] - 212
}) - 213
} - 214
- 215
fn claims(&self, _args: &Value) -> ResourceClaims { - 216
ResourceClaims { - 217
exclusive: false, - 218
read_only: true, - 219
paths: Vec::new(), - 220
} - 221
} - 222
- 223
async fn execute(&self, args: &Value, ctx: &ToolContext) -> ToolOutput { - 224
let Some(url) = args.get("url").and_then(|u| u.as_str()) else { - 225
return ToolOutput::error("missing required parameter: url"); - 226
}; - 227
tokio::select! { - 228
_ = ctx.cancel.cancelled() => ToolOutput::error("fetch cancelled"), - 229
out = self.run(url) => out, - 230
} - 231
} - 232
} - 233
- 234
impl WebFetchTool { - 235
async fn run(&self, raw: &str) -> ToolOutput { - 236
let url = match parse_target(raw) { - 237
Ok(u) => u, - 238
Err(e) => return ToolOutput::error(e), - 239
}; - 240
let Some(host) = url.host_str().map(str::to_string) else { - 241
return ToolOutput::error("invalid url: missing host"); - 242
}; - 243
let screened = match tokio::task::spawn_blocking(move || ssrf_guard(&host)).await { - 244
Ok(r) => r, - 245
Err(e) => return ToolOutput::error(format!("address screening failed: {e}")), - 246
}; - 247
if let Err(rejection) = screened { - 248
return ToolOutput::error(rejection.to_string()); - 249
} - 250
- 251
let client = match reqwest::Client::builder() - 252
.redirect(redirect_policy()) - 253
.timeout(TOTAL_TIMEOUT) - 254
.build() - 255
{ - 256
Ok(c) => c, - 257
Err(e) => return ToolOutput::error(format!("client build failed: {e}")), - 258
}; - 259
- 260
let resp = match client.get(url).send().await { - 261
Ok(r) => r, - 262
Err(e) => return ToolOutput::error(request_error_message(&e)), - 263
}; - 264
let status = resp.status(); - 265
let final_url = resp.url().clone(); - 266
let content_type = resp - 267
.headers() - 268
.get(reqwest::header::CONTENT_TYPE) - 269
.and_then(|v| v.to_str().ok()) - 270
.map(str::trim) - 271
.unwrap_or_default() - 272
.to_owned(); - 273
if !content_type_allowed(content_type.as_str()) { - 274
let shown = if content_type.is_empty() { - 275
"missing" - 276
} else { - 277
content_type.as_str() - 278
}; - 279
return ToolOutput::error(format!("unsupported content type: {shown}")); - 280
} - 281
- 282
let mut resp = resp; - 283
let mut body: Vec<u8> = Vec::new(); - 284
loop { - 285
match resp.chunk().await { - 286
Ok(Some(chunk)) => { - 287
if body.len() + chunk.len() > MAX_BODY_BYTES { - 288
return ToolOutput::error(format!( - 289
"response body exceeds the {MAX_BODY_BYTES} byte cap" - 290
)); - 291
} - 292
body.extend_from_slice(&chunk); - 293
} - 294
Ok(None) => break, - 295
Err(e) => return ToolOutput::error(request_error_message(&e)), - 296
} - 297
} - 298
- 299
let text = String::from_utf8_lossy(&body); - 300
let header = format!( - 301
"[webfetch] GET {final_url} -> {status} ({content_type}, {count} bytes)", - 302
count = body.len() - 303
); - 304
ToolOutput::ok(format!("{header}\n{text}")) - 305
} - 306
} - 307
- 308
#[cfg(test)] - 309
mod tests { - 310
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 311
- 312
use super::*; - 313
- 314
use crate::context::shared_ctx; - 315
- 316
#[test] - 317
fn scheme_validation_matrix() { - 318
for ok in [ - 319
"http://example.com/", - 320
"https://example.com/", - 321
"HTTPS://Example.COM/", - 322
] { - 323
assert!(parse_target(ok).is_ok(), "expected {ok} to pass"); - 324
} - 325
for bad in [ - 326
"ftp://example.com/", - 327
"file:///etc/passwd", - 328
"javascript:alert(1)", - 329
"data:text/plain,hi", - 330
"unix:/var/run/sock", - 331
"//example.com/no-scheme", - 332
"example.com/bare", - 333
"", - 334
] { - 335
assert!(parse_target(bad).is_err(), "expected {bad} to fail"); - 336
} - 337
let msg = parse_target("ftp://example.com/").unwrap_err(); - 338
assert!(msg.contains("ftp") && msg.contains("scheme"), "{msg}"); - 339
} - 340
- 341
#[tokio::test] - 342
async fn missing_url_parameter_is_a_typed_error() { - 343
let ctx = shared_ctx(std::path::Path::new(".")); - 344
let out = WebFetchTool.execute(&serde_json::json!({}), &ctx).await; - 345
assert!(out.is_error); - 346
assert!(out.content.contains("missing required parameter")); - 347
} - 348
- 349
#[tokio::test] - 350
async fn unsupported_scheme_is_a_typed_error_before_any_connection() { - 351
let ctx = shared_ctx(std::path::Path::new(".")); - 352
let out = WebFetchTool - 353
.execute(&serde_json::json!({"url": "ftp://example.com/x"}), &ctx) - 354
.await; - 355
assert!(out.is_error); - 356
assert!(out.content.contains("ftp")); - 357
} - 358
- 359
#[tokio::test] - 360
async fn ssrf_matrix_blocks_dangerous_ranges_via_execute() { - 361
let cases = [ - 362
("http://127.0.0.1/x", "loopback"), - 363
("http://[::1]/x", "loopback"), - 364
("http://0.0.0.0/x", "unspecified"), - 365
("http://[::]/x", "unspecified"), - 366
("http://10.11.12.13/x", "private"), - 367
("http://172.16.0.1/x", "private"), - 368
("http://172.31.254.3/x", "private"), - 369
("http://192.168.50.50/x", "private"), - 370
("http://169.254.169.254/latest/meta-data/", "link-local"), - 371
("http://[fe80::1]/x", "link-local"), - 372
("http://[fd00::1]/x", "unique-local"), - 373
("http://[fc00::]/x", "unique-local"), - 374
("http://[::ffff:10.0.0.1]/x", "private"), - 375
("http://[::ffff:127.0.0.1]/x", "loopback"), - 376
("http://[::ffff:169.254.169.254]/", "link-local"), - 377
]; - 378
let ctx = shared_ctx(std::path::Path::new(".")); - 379
for (url, class) in cases { - 380
let out = WebFetchTool - 381
.execute(&serde_json::json!({"url": url}), &ctx) - 382
.await; - 383
assert!( - 384
out.is_error, - 385
"expected {url} to be blocked, got: {}", - 386
out.content - 387
); - 388
assert!( - 389
out.content.contains("blocked") && out.content.contains(class), - 390
"unexpected message for {url}: {}", - 391
out.content - 392
); - 393
} - 394
} - 395
- 396
#[test] - 397
fn mapped_ipv6_addresses_are_screened_like_ipv4() { - 398
assert_eq!( - 399
classify("::ffff:10.0.0.1".parse().unwrap()), - 400
Some("private RFC1918") - 401
); - 402
assert_eq!( - 403
classify("::ffff:172.16.9.9".parse().unwrap()), - 404
Some("private RFC1918") - 405
); - 406
assert_eq!( - 407
classify("::ffff:192.168.0.1".parse().unwrap()), - 408
Some("private RFC1918") - 409
); - 410
assert_eq!( - 411
classify("::ffff:127.0.0.1".parse().unwrap()), - 412
Some("loopback") - 413
); - 414
assert_eq!( - 415
classify("::ffff:169.254.169.254".parse().unwrap()), - 416
Some("link-local") - 417
); - 418
assert_eq!( - 419
classify("::ffff:0.0.0.0".parse().unwrap()), - 420
Some("unspecified") - 421
); - 422
} - 423
- 424
#[test] - 425
fn public_ranges_are_not_classified_as_internal() { - 426
assert_eq!(classify("8.8.8.8".parse().unwrap()), None); - 427
assert_eq!(classify("172.32.0.1".parse().unwrap()), None); - 428
assert_eq!(classify("198.51.100.7".parse().unwrap()), None); - 429
assert_eq!(classify("2606:4700::1111".parse().unwrap()), None); - 430
assert_eq!(classify("::ffff:8.8.8.8".parse().unwrap()), None); - 431
} - 432
- 433
#[test] - 434
fn redirect_limit_allows_three_and_rejects_the_fourth() { - 435
assert!(follow_redirect(0)); - 436
assert!(follow_redirect(1)); - 437
assert!(follow_redirect(2)); - 438
assert!(!follow_redirect(3)); - 439
assert!(!follow_redirect(4)); - 440
} - 441
- 442
#[test] - 443
fn content_type_filter_accepts_only_document_types() { - 444
for accepted in [ - 445
"text/html; charset=utf-8", - 446
"text/plain", - 447
"Text/Markdown", - 448
"application/json", - 449
"application/xhtml+xml", - 450
"application/xml", - 451
"text/xml", - 452
"application/atom+xml", - 453
] { - 454
assert!( - 455
content_type_allowed(accepted), - 456
"expected {accepted} to be accepted" - 457
); - 458
} - 459
for rejected in [ - 460
"image/png", - 461
"image/jpeg", - 462
"application/octet-stream", - 463
"video/mp4", - 464
"audio/mpeg", - 465
"multipart/form-data", - 466
"", - 467
] { - 468
assert!( - 469
!content_type_allowed(rejected), - 470
"expected {rejected:?} to be rejected" - 471
); - 472
} - 473
} - 474
- 475
#[tokio::test] - 476
#[ignore = "requires network access"] - 477
async fn live_fetch_returns_header_line_and_body() { - 478
let ctx = shared_ctx(std::path::Path::new(".")); - 479
let out = WebFetchTool - 480
.execute(&serde_json::json!({"url": "https://example.com"}), &ctx) - 481
.await; - 482
assert!(!out.is_error, "live fetch failed: {}", out.content); - 483
assert!( - 484
out.content.starts_with("[webfetch] GET "), - 485
"{}", - 486
out.content - 487
); - 488
} - 489
} - 490
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.