From c8851587183cceb042a1393c91812d88e3a95745 Mon Sep 17 00:00:00 2001 From: Dhinesh K Date: Thu, 18 Jun 2026 21:05:44 +0530 Subject: [PATCH] feat(awc): add HTTP proxy support via environment variables Add support for HTTP_PROXY/HTTPS_PROXY/NO_PROXY environment variables to the awc HTTP client, matching the behavior of reqwest and other HTTP clients. Proxied requests use absolute-form URIs per RFC 7230 section 5.3.2. Changes: - actix-http: Add PROXY_REQUEST flag and absolute-form URI encoding - awc: Add ProxyConfig, ClientBuilder::proxy()/no_proxy() methods - awc: DefaultConnector routes connections through proxy when configured - Proxy env vars are read by default; use no_proxy() to disable --- actix-http/CHANGES.md | 1 + actix-http/src/h1/encoder.rs | 50 ++++++- actix-http/src/message.rs | 3 +- actix-http/src/requests/head.rs | 19 +++ awc/CHANGES.md | 2 + awc/src/builder.rs | 25 +++- awc/src/client/mod.rs | 2 + awc/src/client/proxy.rs | 223 ++++++++++++++++++++++++++++++++ awc/src/connect.rs | 52 ++++++-- awc/src/lib.rs | 2 +- 10 files changed, 361 insertions(+), 18 deletions(-) create mode 100644 awc/src/client/proxy.rs diff --git a/actix-http/CHANGES.md b/actix-http/CHANGES.md index 6e4f8ec32..f4ef45559 100644 --- a/actix-http/CHANGES.md +++ b/actix-http/CHANGES.md @@ -2,6 +2,7 @@ ## Unreleased +- Add `RequestHead::set_proxy_request()` / `is_proxy_request()` to support absolute-form URI encoding for HTTP proxy requests (RFC 7230 section 5.3.2). - When configured, gracefully close HTTP/1 connections after early responses to unread request bodies. [#3967] - Wake HTTP/1 payload receivers with an incomplete-payload error when the sender is dropped before EOF. [#3100] - Update `foldhash` dependency to `0.2`. diff --git a/actix-http/src/h1/encoder.rs b/actix-http/src/h1/encoder.rs index 1853843c3..b44dc6054 100644 --- a/actix-http/src/h1/encoder.rs +++ b/actix-http/src/h1/encoder.rs @@ -305,11 +305,23 @@ impl MessageType for RequestHeadType { fn encode_status(&mut self, dst: &mut BytesMut) -> io::Result<()> { let head = self.as_ref(); dst.reserve(256 + head.headers.len() * AVERAGE_HEADER_SIZE); + + // RFC 7230 section 5.3.2: use absolute-form URI for proxy requests + let request_target = if head.is_proxy_request() { + head.uri.to_string() + } else { + head.uri + .path_and_query() + .map(|u| u.as_str()) + .unwrap_or("/") + .to_owned() + }; + write!( helpers::MutWriter(dst), "{} {} {}", head.method, - head.uri.path_and_query().map(|u| u.as_str()).unwrap_or("/"), + request_target, match head.version { Version::HTTP_09 => "HTTP/0.9", Version::HTTP_10 => "HTTP/1.0", @@ -664,6 +676,42 @@ mod tests { assert!(data.contains("date: date\r\n")); } + #[test] + fn test_proxy_request_absolute_uri() { + let mut bytes = BytesMut::with_capacity(2048); + let mut head = RequestHead::default(); + head.uri = "http://example.com/foo?bar=1".parse().unwrap(); + head.method = http::Method::GET; + head.set_proxy_request(true); + + let mut head = RequestHeadType::Owned(head); + head.encode_status(&mut bytes).unwrap(); + + let data = String::from_utf8(Vec::from(bytes.split().freeze().as_ref())).unwrap(); + assert!( + data.starts_with("GET http://example.com/foo?bar=1 HTTP/1.1"), + "Expected absolute-form URI for proxy request, got: {data}", + ); + } + + #[test] + fn test_non_proxy_request_origin_form() { + let mut bytes = BytesMut::with_capacity(2048); + let mut head = RequestHead::default(); + head.uri = "http://example.com/foo?bar=1".parse().unwrap(); + head.method = http::Method::GET; + // proxy flag is NOT set + + let mut head = RequestHeadType::Owned(head); + head.encode_status(&mut bytes).unwrap(); + + let data = String::from_utf8(Vec::from(bytes.split().freeze().as_ref())).unwrap(); + assert!( + data.starts_with("GET /foo?bar=1 HTTP/1.1"), + "Expected origin-form URI for non-proxy request, got: {data}", + ); + } + #[actix_rt::test] async fn test_no_content_length() { let mut bytes = BytesMut::with_capacity(2048); diff --git a/actix-http/src/message.rs b/actix-http/src/message.rs index d2241b229..56e2da5f8 100644 --- a/actix-http/src/message.rs +++ b/actix-http/src/message.rs @@ -23,7 +23,8 @@ bitflags! { const UPGRADE = 0b0000_0100; const EXPECT = 0b0000_1000; const NO_CHUNKING = 0b0001_0000; - const CAMEL_CASE = 0b0010_0000; + const CAMEL_CASE = 0b0010_0000; + const PROXY_REQUEST = 0b0100_0000; } } diff --git a/actix-http/src/requests/head.rs b/actix-http/src/requests/head.rs index ddc9dd98f..74d97afa7 100644 --- a/actix-http/src/requests/head.rs +++ b/actix-http/src/requests/head.rs @@ -144,6 +144,25 @@ impl RequestHead { pub(crate) fn set_expect(&mut self) { self.flags.insert(Flags::EXPECT); } + + /// Returns whether this request should use absolute-form URI (for proxy requests). + #[inline] + pub fn is_proxy_request(&self) -> bool { + self.flags.contains(Flags::PROXY_REQUEST) + } + + /// Sets whether this request should use absolute-form URI in the request line. + /// + /// When enabled, the full URI (including scheme and authority) is sent in the request line, + /// as required by HTTP/1.1 for requests to proxies (RFC 7230 section 5.3.2). + #[inline] + pub fn set_proxy_request(&mut self, val: bool) { + if val { + self.flags.insert(Flags::PROXY_REQUEST); + } else { + self.flags.remove(Flags::PROXY_REQUEST); + } + } } #[allow(clippy::large_enum_variant)] diff --git a/awc/CHANGES.md b/awc/CHANGES.md index 9888c66c3..69dfe5285 100644 --- a/awc/CHANGES.md +++ b/awc/CHANGES.md @@ -2,6 +2,8 @@ ## Unreleased +- Add HTTP proxy support via `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` environment variables, read by default. +- Add `ProxyConfig` type and `ClientBuilder::proxy()` / `ClientBuilder::no_proxy()` methods for explicit proxy configuration. - Add camel-case header controls to `WebsocketsRequest` via `camel_case_headers()` and `set_camel_case_headers()`. [#3953] - Update `hickory-resolver` dependency to `0.26.1`. - Update `rand` dependency to `0.10`. diff --git a/awc/src/builder.rs b/awc/src/builder.rs index 5aae394f8..81c481af1 100644 --- a/awc/src/builder.rs +++ b/awc/src/builder.rs @@ -11,7 +11,8 @@ use base64::prelude::*; use crate::{ client::{ - ClientConfig, ConnectInfo, Connector, ConnectorService, TcpConnectError, TcpConnection, + proxy::ProxyConfig, ClientConfig, ConnectInfo, Connector, ConnectorService, + TcpConnectError, TcpConnection, }, connect::DefaultConnector, error::SendRequestError, @@ -34,6 +35,7 @@ pub struct ClientBuilder { middleware: M, local_address: Option, max_redirects: u8, + proxy: ProxyConfig, } impl ClientBuilder { @@ -63,6 +65,7 @@ impl ClientBuilder { middleware: (), local_address: None, max_redirects: 10, + proxy: ProxyConfig::from_env(), } } } @@ -93,6 +96,7 @@ where stream_window_size: self.stream_window_size, conn_window_size: self.conn_window_size, max_redirects: self.max_redirects, + proxy: self.proxy, } } @@ -159,6 +163,22 @@ where self } + /// Set proxy configuration. + /// + /// By default, proxy settings are read from environment variables + /// (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`). Use this method to override with + /// an explicit configuration. + pub fn proxy(mut self, proxy: ProxyConfig) -> Self { + self.proxy = proxy; + self + } + + /// Disable proxy usage, ignoring environment variables. + pub fn no_proxy(mut self) -> Self { + self.proxy = ProxyConfig::default(); + self + } + /// Do not add fundamental default request headers. /// /// By default `Date` and `User-Agent` headers are set. @@ -245,6 +265,7 @@ where connector: self.connector, local_address: self.local_address, max_redirects: self.max_redirects, + proxy: self.proxy, } } @@ -284,7 +305,7 @@ where connector = connector.local_address(val); } - let connector = DefaultConnector::new(connector.finish()); + let connector = DefaultConnector::new(connector.finish()).with_proxy(self.proxy); let connector = boxed::rc_service(self.middleware.new_transform(connector)); Client(ClientConfig { diff --git a/awc/src/client/mod.rs b/awc/src/client/mod.rs index c9fa37253..41c00a7d1 100644 --- a/awc/src/client/mod.rs +++ b/awc/src/client/mod.rs @@ -18,11 +18,13 @@ mod error; mod h1proto; mod h2proto; mod pool; +pub(crate) mod proxy; pub use self::{ connection::{Connection, ConnectionIo}, connector::{Connector, ConnectorService}, error::{ConnectError, FreezeRequestError, InvalidUrl, SendRequestError}, + proxy::ProxyConfig, }; #[derive(Clone)] diff --git a/awc/src/client/proxy.rs b/awc/src/client/proxy.rs new file mode 100644 index 000000000..40939534d --- /dev/null +++ b/awc/src/client/proxy.rs @@ -0,0 +1,223 @@ +use std::env; + +use http::Uri; + +/// HTTP proxy configuration for the client connector. +/// +/// Determines which proxy (if any) should be used for a given request URI, +/// based on explicit configuration or environment variables (`HTTP_PROXY`, +/// `HTTPS_PROXY`, `NO_PROXY`). +#[derive(Clone, Debug)] +pub struct ProxyConfig { + http_proxy: Option, + https_proxy: Option, + no_proxy: Option, +} + +impl ProxyConfig { + /// Creates a proxy configuration by reading standard environment variables. + /// + /// Reads `HTTP_PROXY` (or `http_proxy`), `HTTPS_PROXY` (or `https_proxy`), + /// and `NO_PROXY` (or `no_proxy`). + pub fn from_env() -> Self { + let http_proxy = env_var_uri("HTTP_PROXY").or_else(|| env_var_uri("http_proxy")); + let https_proxy = env_var_uri("HTTPS_PROXY").or_else(|| env_var_uri("https_proxy")); + let no_proxy = env::var("NO_PROXY") + .or_else(|_| env::var("no_proxy")) + .ok() + .map(|s| NoProxy::parse(&s)); + + Self { + http_proxy, + https_proxy, + no_proxy, + } + } + + /// Creates a proxy configuration with an explicit HTTP proxy URI. + pub fn with_http_proxy(mut self, proxy: Uri) -> Self { + self.http_proxy = Some(proxy); + self + } + + /// Creates a proxy configuration with an explicit HTTPS proxy URI. + pub fn with_https_proxy(mut self, proxy: Uri) -> Self { + self.https_proxy = Some(proxy); + self + } + + /// Sets the no-proxy list (comma-separated hostnames/domains). + pub fn with_no_proxy(mut self, no_proxy: &str) -> Self { + self.no_proxy = Some(NoProxy::parse(no_proxy)); + self + } + + /// Returns the proxy URI to use for the given target URI, or `None` if no proxy applies. + pub fn proxy_for_uri(&self, uri: &Uri) -> Option<&Uri> { + if let Some(ref no_proxy) = self.no_proxy { + if let Some(host) = uri.host() { + if no_proxy.matches(host) { + return None; + } + } + } + + match uri.scheme_str() { + Some("https") | Some("wss") => self.https_proxy.as_ref(), + _ => self.http_proxy.as_ref(), + } + } + + /// Returns `true` if any proxy is configured. + pub fn has_proxy(&self) -> bool { + self.http_proxy.is_some() || self.https_proxy.is_some() + } +} + +impl Default for ProxyConfig { + fn default() -> Self { + Self { + http_proxy: None, + https_proxy: None, + no_proxy: None, + } + } +} + +fn env_var_uri(name: &str) -> Option { + env::var(name) + .ok() + .and_then(|val| { + if val.is_empty() { + return None; + } + match val.parse::() { + Ok(uri) => Some(uri), + Err(err) => { + log::warn!("Invalid proxy URI in {name}: {err}"); + None + } + } + }) +} + +/// Parsed representation of the `NO_PROXY` environment variable. +#[derive(Clone, Debug)] +struct NoProxy { + entries: Vec, + match_all: bool, +} + +impl NoProxy { + fn parse(value: &str) -> Self { + let trimmed = value.trim(); + if trimmed == "*" { + return Self { + entries: Vec::new(), + match_all: true, + }; + } + + let entries = trimmed + .split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect(); + + Self { + entries, + match_all: false, + } + } + + fn matches(&self, host: &str) -> bool { + if self.match_all { + return true; + } + + let host = host.to_lowercase(); + + self.entries.iter().any(|entry| { + if entry.starts_with('.') { + // ".example.com" matches "foo.example.com" and "example.com" + host.ends_with(entry.as_str()) || host == entry[1..] + } else { + host == *entry || host.ends_with(&format!(".{entry}")) + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_proxy_wildcard() { + let np = NoProxy::parse("*"); + assert!(np.matches("anything.example.com")); + assert!(np.matches("localhost")); + } + + #[test] + fn no_proxy_exact_match() { + let np = NoProxy::parse("localhost, example.com"); + assert!(np.matches("localhost")); + assert!(np.matches("example.com")); + assert!(!np.matches("other.com")); + } + + #[test] + fn no_proxy_subdomain_match() { + let np = NoProxy::parse("example.com"); + assert!(np.matches("example.com")); + assert!(np.matches("sub.example.com")); + assert!(!np.matches("notexample.com")); + } + + #[test] + fn no_proxy_dot_prefix() { + let np = NoProxy::parse(".example.com"); + assert!(np.matches("example.com")); + assert!(np.matches("sub.example.com")); + assert!(!np.matches("notexample.com")); + } + + #[test] + fn no_proxy_case_insensitive() { + let np = NoProxy::parse("Example.COM"); + assert!(np.matches("example.com")); + assert!(np.matches("EXAMPLE.COM")); + } + + #[test] + fn proxy_for_uri_http() { + let config = ProxyConfig::default() + .with_http_proxy("http://proxy:8080".parse().unwrap()); + let uri: Uri = "http://example.com/foo".parse().unwrap(); + assert!(config.proxy_for_uri(&uri).is_some()); + } + + #[test] + fn proxy_for_uri_no_proxy() { + let config = ProxyConfig::default() + .with_http_proxy("http://proxy:8080".parse().unwrap()) + .with_no_proxy("example.com"); + let uri: Uri = "http://example.com/foo".parse().unwrap(); + assert!(config.proxy_for_uri(&uri).is_none()); + } + + #[test] + fn proxy_for_uri_https() { + let config = ProxyConfig::default() + .with_https_proxy("http://proxy:8080".parse().unwrap()); + let uri: Uri = "https://example.com/foo".parse().unwrap(); + assert!(config.proxy_for_uri(&uri).is_some()); + } + + #[test] + fn no_proxy_empty_string() { + let np = NoProxy::parse(""); + assert!(!np.matches("anything")); + } +} diff --git a/awc/src/connect.rs b/awc/src/connect.rs index 14ed9e958..7eb103cc0 100644 --- a/awc/src/connect.rs +++ b/awc/src/connect.rs @@ -13,7 +13,10 @@ use futures_core::{future::LocalBoxFuture, ready}; use crate::{ any_body::AnyBody, - client::{Connect as ClientConnect, ConnectError, Connection, ConnectionIo, SendRequestError}, + client::{ + proxy::ProxyConfig, Connect as ClientConnect, ConnectError, Connection, ConnectionIo, + SendRequestError, + }, ClientResponse, }; @@ -82,11 +85,20 @@ impl ConnectResponse { pub struct DefaultConnector { connector: S, + proxy: ProxyConfig, } impl DefaultConnector { pub(crate) fn new(connector: S) -> Self { - Self { connector } + Self { + connector, + proxy: ProxyConfig::default(), + } + } + + pub(crate) fn with_proxy(mut self, proxy: ProxyConfig) -> Self { + self.proxy = proxy; + self } } @@ -101,19 +113,33 @@ where actix_service::forward_ready!(connector); - fn call(&self, req: ConnectRequest) -> Self::Future { - // connect to the host - let fut = match req { - ConnectRequest::Client(ref head, .., addr) => self.connector.call(ClientConnect { - uri: head.as_ref().uri.clone(), - addr, - }), - ConnectRequest::Tunnel(ref head, addr) => self.connector.call(ClientConnect { - uri: head.uri.clone(), - addr, - }), + fn call(&self, mut req: ConnectRequest) -> Self::Future { + let (target_uri, addr) = match req { + ConnectRequest::Client(ref head, .., addr) => (head.as_ref().uri.clone(), addr), + ConnectRequest::Tunnel(ref head, addr) => (head.uri.clone(), addr), }; + let connect_uri = if let Some(proxy_uri) = self.proxy.proxy_for_uri(&target_uri) { + // Set the proxy flag on the request head so the encoder uses absolute-form URI + match req { + ConnectRequest::Client(ref mut head, ..) => match head { + RequestHeadType::Owned(ref mut h) => h.set_proxy_request(true), + RequestHeadType::Rc(_, _) => { + log::warn!("Cannot set proxy flag on Rc; request will use origin-form URI"); + } + }, + ConnectRequest::Tunnel(..) => {} + } + proxy_uri.clone() + } else { + target_uri + }; + + let fut = self.connector.call(ClientConnect { + uri: connect_uri, + addr, + }); + ConnectRequestFuture::Connection { fut, req: Some(req), diff --git a/awc/src/lib.rs b/awc/src/lib.rs index 360b3db0e..4b225e4e1 100644 --- a/awc/src/lib.rs +++ b/awc/src/lib.rs @@ -138,7 +138,7 @@ pub mod http { pub use self::responses::{ClientResponse, JsonBody, MessageBody, ResponseBody}; pub use self::{ builder::ClientBuilder, - client::{Client, Connect, Connector}, + client::{Client, Connect, Connector, ProxyConfig}, connect::{BoxConnectorService, BoxedSocket, ConnectRequest, ConnectResponse}, frozen::{FrozenClientRequest, FrozenSendBuilder}, request::ClientRequest,