Skip to content

Commit c5a3c25

Browse files
authored
Merge pull request #52 from Tcode-Motion/fix/web-ssrf-2495096785143464329
🔒 Fix SSRF vulnerability in std.web.fetch
2 parents e9356cb + 72e4df7 commit c5a3c25

4 files changed

Lines changed: 99 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resolver_test

27.1 MB
Binary file not shown.

stdlib/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ uuid = { version = "1", features = ["v4"] }
3636
rustls = { version = "0.23", optional = true }
3737
tokio = { version = "1", features = ["rt", "macros", "sync", "time"], optional = true }
3838
hex = "0.4.3"
39-
shlex = "2.0.1"
39+
<

stdlib/src/web.rs

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,87 @@ use crate::{StdFunction, StdlibModule, StdlibRegistry};
22
use std::collections::HashMap;
33
use std::rc::Rc;
44
use std::sync::atomic::{AtomicBool, Ordering};
5+
use std::net::{ToSocketAddrs, IpAddr, SocketAddr};
6+
use url::Url;
7+
use ureq::Resolver;
8+
9+
fn is_safe_ip(ip: &IpAddr) -> bool {
10+
match ip {
11+
IpAddr::V4(ipv4) => {
12+
!ipv4.is_private()
13+
&& !ipv4.is_loopback()
14+
&& !ipv4.is_link_local()
15+
&& !ipv4.is_broadcast()
16+
&& !ipv4.is_documentation()
17+
&& !ipv4.is_unspecified()
18+
}
19+
IpAddr::V6(ipv6) => {
20+
if let Some(ipv4) = ipv6.to_ipv4() {
21+
// Check IPv4-mapped IPv6
22+
return is_safe_ip(&IpAddr::V4(ipv4));
23+
}
24+
!ipv6.is_loopback()
25+
&& !ipv6.is_unspecified()
26+
// IPv6 specific checks
27+
&& (ipv6.segments()[0] & 0xfe00) != 0xfc00 // Unique Local Address
28+
&& (ipv6.segments()[0] & 0xffc0) != 0xfe80 // Link Local Address
29+
}
30+
}
31+
}
32+
33+
fn is_safe_url(url_str: &str) -> bool {
34+
let Ok(parsed_url) = Url::parse(url_str) else {
35+
return false; // Invalid URL
36+
};
37+
38+
match parsed_url.scheme() {
39+
"http" | "https" => {}
40+
_ => return false, // Block file://, ftp://, gopher://, etc.
41+
}
42+
43+
let host = match parsed_url.host_str() {
44+
Some(h) => h,
45+
None => return false, // No host provided
46+
};
47+
48+
let port = parsed_url.port_or_known_default().unwrap_or(80);
49+
let addr_str = format!("{}:{}", host, port);
50+
51+
// Resolve the domain to IPs
52+
let addrs = match addr_str.to_socket_addrs() {
53+
Ok(a) => a,
54+
Err(_) => return false, // DNS resolution failed
55+
};
56+
57+
for addr in addrs {
58+
if !is_safe_ip(&addr.ip()) {
59+
return false; // Found an unsafe IP
60+
}
61+
}
62+
63+
true
64+
}
65+
66+
struct SafeResolver;
67+
68+
impl Resolver for SafeResolver {
69+
fn resolve(&self, netloc: &str) -> std::io::Result<Vec<SocketAddr>> {
70+
let addrs: Vec<SocketAddr> = netloc.to_socket_addrs()?.collect();
71+
let mut safe_addrs = Vec::new();
72+
for addr in addrs {
73+
if is_safe_ip(&addr.ip()) {
74+
safe_addrs.push(addr);
75+
}
76+
}
77+
if safe_addrs.is_empty() {
78+
return Err(std::io::Error::new(
79+
std::io::ErrorKind::PermissionDenied,
80+
"DNS resolution returned only blocked/internal IP addresses (SSRF prevention).",
81+
));
82+
}
83+
Ok(safe_addrs)
84+
}
85+
}
586
use std::sync::Mutex;
687
use std::thread;
788
use techscript_runtime::{
@@ -432,7 +513,22 @@ impl StdlibRegistry {
432513
arity: 1,
433514
callback: |_ctx, args| {
434515
let url = args[0].to_string();
435-
let body = ureq::get(&url)
516+
517+
if !is_safe_url(&url) {
518+
return Err(RuntimeError::new(
519+
techscript_runtime::error::RuntimeErrorKind::InvalidOperation(
520+
format!("Access denied: the URL '{}' points to a blocked or internal destination (SSRF prevention).", url)
521+
),
522+
None,
523+
None,
524+
));
525+
}
526+
527+
let agent = ureq::builder()
528+
.resolver(SafeResolver)
529+
.redirects(0)
530+
.build();
531+
let body = agent.get(&url)
436532
.call()
437533
.map_err(|e| {
438534
RuntimeError::new(

0 commit comments

Comments
 (0)