Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added resolver_test
Binary file not shown.
2 changes: 1 addition & 1 deletion stdlib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ uuid = { version = "1", features = ["v4"] }
rustls = { version = "0.23", optional = true }
tokio = { version = "1", features = ["rt", "macros", "sync", "time"], optional = true }
hex = "0.4.3"
shlex = "2.0.1"
<
98 changes: 97 additions & 1 deletion stdlib/src/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,87 @@ use crate::{StdFunction, StdlibModule, StdlibRegistry};
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::net::{ToSocketAddrs, IpAddr, SocketAddr};
use url::Url;
use ureq::Resolver;

fn is_safe_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(ipv4) => {
!ipv4.is_private()
&& !ipv4.is_loopback()
&& !ipv4.is_link_local()
&& !ipv4.is_broadcast()
&& !ipv4.is_documentation()
&& !ipv4.is_unspecified()
}
IpAddr::V6(ipv6) => {
if let Some(ipv4) = ipv6.to_ipv4() {
// Check IPv4-mapped IPv6
return is_safe_ip(&IpAddr::V4(ipv4));
}
!ipv6.is_loopback()
&& !ipv6.is_unspecified()
// IPv6 specific checks
&& (ipv6.segments()[0] & 0xfe00) != 0xfc00 // Unique Local Address
&& (ipv6.segments()[0] & 0xffc0) != 0xfe80 // Link Local Address
}
}
}

fn is_safe_url(url_str: &str) -> bool {
let Ok(parsed_url) = Url::parse(url_str) else {
return false; // Invalid URL
};

match parsed_url.scheme() {
"http" | "https" => {}
_ => return false, // Block file://, ftp://, gopher://, etc.
}

let host = match parsed_url.host_str() {
Some(h) => h,
None => return false, // No host provided
};

let port = parsed_url.port_or_known_default().unwrap_or(80);
let addr_str = format!("{}:{}", host, port);

// Resolve the domain to IPs
let addrs = match addr_str.to_socket_addrs() {
Ok(a) => a,
Err(_) => return false, // DNS resolution failed
};

for addr in addrs {
if !is_safe_ip(&addr.ip()) {
return false; // Found an unsafe IP
}
}

true
}

struct SafeResolver;

impl Resolver for SafeResolver {
fn resolve(&self, netloc: &str) -> std::io::Result<Vec<SocketAddr>> {
let addrs: Vec<SocketAddr> = netloc.to_socket_addrs()?.collect();
let mut safe_addrs = Vec::new();
for addr in addrs {
if is_safe_ip(&addr.ip()) {
safe_addrs.push(addr);
}
}
if safe_addrs.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"DNS resolution returned only blocked/internal IP addresses (SSRF prevention).",
));
}
Ok(safe_addrs)
}
}
use std::sync::Mutex;
use std::thread;
use techscript_runtime::{
Expand Down Expand Up @@ -432,7 +513,22 @@ impl StdlibRegistry {
arity: 1,
callback: |_ctx, args| {
let url = args[0].to_string();
let body = ureq::get(&url)

if !is_safe_url(&url) {
return Err(RuntimeError::new(
techscript_runtime::error::RuntimeErrorKind::InvalidOperation(
format!("Access denied: the URL '{}' points to a blocked or internal destination (SSRF prevention).", url)
),
None,
None,
));
}

let agent = ureq::builder()
.resolver(SafeResolver)
.redirects(0)
.build();
let body = agent.get(&url)
.call()
.map_err(|e| {
RuntimeError::new(
Expand Down
Loading