Add the browser send proxy and web sender (#572)

This commit is contained in:
Gregory Schier
2026-08-18 08:28:01 -07:00
committed by GitHub
parent 4b2dcf9a1a
commit 69083918f9
53 changed files with 2731 additions and 378 deletions
+11 -3
View File
@@ -1,4 +1,4 @@
use crate::dns::LocalhostResolver;
use crate::dns::{AddressFilter, LocalhostResolver};
use crate::error::Result;
use log::{debug, info, warn};
use reqwest::{Client, ClientBuilder, Proxy, redirect};
@@ -103,13 +103,18 @@ pub struct HttpConnectionOptions {
pub proxy: HttpConnectionProxySetting,
pub client_certificate: Option<ClientCertificateConfig>,
pub dns_overrides: Vec<DnsOverride>,
/// Refuse connections to addresses a hostname resolves to. `None` means
/// every resolved address is connectable, which is what the desktop wants:
/// a user sending to their own machine or their own network is the point.
/// A hosted sender is the caller that supplies one.
pub address_filter: Option<AddressFilter>,
}
impl HttpConnectionOptions {
/// Build a reqwest Client and return it along with the DNS resolver.
/// The resolver is returned separately so it can be configured per-request
/// to emit DNS timing events to the appropriate channel.
pub(crate) fn build_client(&self) -> Result<(ConfiguredClient, Arc<LocalhostResolver>)> {
pub fn build_client(&self) -> Result<(ConfiguredClient, Arc<LocalhostResolver>)> {
let mut client = client_builder()
.connection_verbose(true)
.redirect(redirect::Policy::none())
@@ -135,7 +140,10 @@ impl HttpConnectionOptions {
}
// Configure DNS resolver - keep a reference to configure per-request
let resolver = LocalhostResolver::new(self.dns_overrides.clone());
let resolver = LocalhostResolver::with_address_filter(
self.dns_overrides.clone(),
self.address_filter.clone(),
);
client = client.dns_resolver(resolver.clone());
// Configure proxy
+39
View File
@@ -20,15 +20,32 @@ pub struct ResolvedOverride {
pub ipv6: Vec<Ipv6Addr>,
}
/// A veto on the addresses a hostname resolves to, consulted after resolution
/// and before any connection is made. Returning `Err` refuses the whole lookup
/// with that message; a hostname is never partially allowed.
///
/// A hosted sender uses this to refuse private and metadata ranges no matter
/// what name they hide behind. Checking here rather than on the URL is what
/// catches a public hostname that resolves to an internal address.
pub type AddressFilter = Arc<dyn Fn(IpAddr) -> std::result::Result<(), String> + Send + Sync>;
#[derive(Clone)]
pub struct LocalhostResolver {
fallback: HyperGaiResolver,
event_tx: Arc<RwLock<Option<mpsc::Sender<HttpResponseEvent>>>>,
overrides: Arc<HashMap<String, ResolvedOverride>>,
address_filter: Option<AddressFilter>,
}
impl LocalhostResolver {
pub fn new(dns_overrides: Vec<DnsOverride>) -> Arc<Self> {
Self::with_address_filter(dns_overrides, None)
}
pub fn with_address_filter(
dns_overrides: Vec<DnsOverride>,
address_filter: Option<AddressFilter>,
) -> Arc<Self> {
let resolver = HyperGaiResolver::new();
// Pre-parse DNS overrides into a lookup map
@@ -55,9 +72,25 @@ impl LocalhostResolver {
fallback: resolver,
event_tx: Arc::new(RwLock::new(None)),
overrides: Arc::new(overrides),
address_filter,
})
}
/// Apply the address filter, if any, to a resolved address list.
fn filter_addrs(
filter: &Option<AddressFilter>,
addrs: &[SocketAddr],
) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Some(filter) = filter {
for addr in addrs {
if let Err(reason) = filter(addr.ip()) {
return Err(Box::new(std::io::Error::other(reason)));
}
}
}
Ok(())
}
/// Set the event sender for the current request.
/// This should be called before each request to direct DNS events
/// to the appropriate channel.
@@ -72,6 +105,7 @@ impl Resolve for LocalhostResolver {
let host = name.as_str().to_lowercase();
let event_tx = self.event_tx.clone();
let overrides = self.overrides.clone();
let address_filter = self.address_filter.clone();
info!("DNS resolve called for: {}", host);
@@ -94,6 +128,8 @@ impl Resolve for LocalhostResolver {
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
return Box::pin(async move {
Self::filter_addrs(&address_filter, &addrs)?;
// Emit DNS event for override
let guard = event_tx.read().await;
if let Some(tx) = guard.as_ref() {
@@ -125,6 +161,8 @@ impl Resolve for LocalhostResolver {
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
return Box::pin(async move {
Self::filter_addrs(&address_filter, &addrs)?;
// Emit DNS event for localhost resolution
let guard = event_tx.read().await;
if let Some(tx) = guard.as_ref() {
@@ -161,6 +199,7 @@ impl Resolve for LocalhostResolver {
Ok(addrs) => {
// Collect addresses for event emission
let addr_vec: Vec<SocketAddr> = addrs.collect();
Self::filter_addrs(&address_filter, &addr_vec)?;
let addresses: Vec<String> =
addr_vec.iter().map(|a| a.ip().to_string()).collect();
+4 -1
View File
@@ -5,9 +5,12 @@ pub mod decompress;
pub mod dns;
pub mod error;
pub mod manager;
pub mod path_placeholders;
mod proto;
pub mod sender;
pub mod tee_reader;
pub mod transaction;
pub mod types;
// Moved to yaak-models so the browser's wasm host can render requests with the
// same code; re-exported here so existing callers keep their path.
pub use yaak_models::path_placeholders;
-182
View File
@@ -1,182 +0,0 @@
use yaak_models::models::HttpUrlParameter;
pub fn apply_path_placeholders(
url: &str,
parameters: &Vec<HttpUrlParameter>,
) -> (String, Vec<HttpUrlParameter>) {
let mut new_parameters = Vec::new();
let mut url = url.to_string();
for p in parameters {
if !p.enabled || p.name.is_empty() {
continue;
}
// Replace path parameters with values from URL parameters
let old_url_string = url.clone();
url = replace_path_placeholder(&p, url.as_str());
// Remove as param if it modified the URL
if old_url_string == *url {
new_parameters.push(p.to_owned());
}
}
(url, new_parameters)
}
fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
if !p.enabled {
return url.to_string();
}
if !p.name.starts_with(":") {
return url.to_string();
}
// A path placeholder is terminated by `/`, `?`, `#`, end-of-string, or a literal `:`.
// The `:` boundary is what lets `/:id:increment-importance` substitute the `:id`
// placeholder while leaving `:increment-importance` as literal text.
let re = regex::Regex::new(format!("(/){}([/?#:]|$)", p.name).as_str()).unwrap();
let result = re
.replace_all(url, |cap: &regex::Captures| {
format!(
"{}{}{}",
cap[1].to_string(),
urlencoding::encode(p.value.as_str()),
cap[2].to_string()
)
})
.into_owned();
result
}
#[cfg(test)]
mod placeholder_tests {
use crate::path_placeholders::{apply_path_placeholders, replace_path_placeholder};
use yaak_models::models::{HttpRequest, HttpUrlParameter};
#[test]
fn placeholder_middle() {
let p =
HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo/bar"),
"https://example.com/xxx/bar",
);
}
#[test]
fn placeholder_end() {
let p =
HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/xxx",
);
}
#[test]
fn placeholder_query() {
let p =
HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo?:foo"),
"https://example.com/xxx?:foo",
);
}
#[test]
fn placeholder_followed_by_literal_colon() {
// AIP-136-style custom method: `:id` is the placeholder, `:increment-importance`
// is literal text in the same path segment.
let p =
HttpUrlParameter { name: ":id".into(), value: "42".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/tasks/:id:increment-importance"),
"https://example.com/tasks/42:increment-importance",
);
}
#[test]
fn placeholder_missing() {
let p = HttpUrlParameter {
enabled: true,
name: "".to_string(),
value: "".to_string(),
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:missing"),
"https://example.com/:missing",
);
}
#[test]
fn placeholder_disabled() {
let p = HttpUrlParameter {
enabled: false,
name: ":foo".to_string(),
value: "xxx".to_string(),
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/:foo",
);
}
#[test]
fn placeholder_prefix() {
let p =
HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foooo"),
"https://example.com/:foooo",
);
}
#[test]
fn placeholder_encode() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "Hello World".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/Hello%20World",
);
}
#[test]
fn apply_placeholder() {
let req = HttpRequest {
url: "example.com/:a/bar".to_string(),
url_parameters: vec![
HttpUrlParameter {
name: "b".to_string(),
value: "bbb".to_string(),
enabled: true,
id: None,
},
HttpUrlParameter {
name: ":a".to_string(),
value: "aaa".to_string(),
enabled: true,
id: None,
},
],
..Default::default()
};
let (url, url_parameters) = apply_path_placeholders(&req.url, &req.url_parameters);
// Pattern match back to access it
assert_eq!(url, "example.com/aaa/bar");
assert_eq!(url_parameters.len(), 1);
assert_eq!(url_parameters[0].name, "b");
assert_eq!(url_parameters[0].value, "bbb");
}
}