Files
windmill/backend/windmill-trigger-websocket/src/proxy.rs
Ruben Fiszel 3cf7a390a3 fix: pin validated DNS address to close SSRF DNS-rebinding TOCTOU (#10303)
* [ee] fix: pin validated DNS address to close SSRF DNS-rebinding TOCTOU

validate_url_for_ssrf resolved the host, checked every address was
public, then discarded them. Callers re-used the hostname and let stock
reqwest re-resolve at connect time, so a TTL-0 DNS rebinder that answered
a public IP at check-time and an internal one (e.g. 169.254.169.254) at
connect-time slipped straight through the guard.

Return the resolved addresses as a ValidatedTarget and pin them onto the
client that connects, so validate-time and connect-time target the same
address. Covers the AI proxy and worker AI-agent base_url (the primary
readable-SSRF sink), AI OAuth token_url, MCP server + OAuth
registration/discovery/token endpoints, SAML metadata, and the WebSocket
trigger connect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 22abd6d4e229f1206a13ebee8a6a9b808cd82a0d

This commit updates the EE repository reference after PR #684 was merged in windmill-ee-private.

Previous ee-repo-ref: 700feb02ef1b96758ba9425358dbebc83bc02c61

New ee-repo-ref: 22abd6d4e229f1206a13ebee8a6a9b808cd82a0d

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-24 15:04:40 +02:00

470 lines
17 KiB
Rust

//! HTTP CONNECT proxy support for outbound WebSocket connections.
//!
//! `tokio-tungstenite::connect_async` opens a raw TCP socket and does not
//! honour `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`. On networks without
//! direct egress this leaves WebSocket triggers unable to reach the
//! upstream service. This module re-uses the env-var snapshots already
//! parsed by `windmill-common` and, when a proxy applies to the target
//! host, opens an HTTP CONNECT tunnel before delegating the TLS +
//! WebSocket handshake back to tungstenite.
//!
//! When no proxy env vars are set (the common case), this module
//! forwards straight to `tokio_tungstenite::connect_async` so the
//! networking path stays byte-for-byte identical to the previous
//! behaviour.
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use std::io;
use std::net::SocketAddr;
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
net::TcpStream,
};
use tokio_tungstenite::{
client_async_tls_with_config, connect_async,
tungstenite::{
client::IntoClientRequest,
error::{Error as WsError, UrlError},
handshake::client::Response,
},
MaybeTlsStream, WebSocketStream,
};
use url::Url;
use windmill_common::{HTTPS_PROXY, HTTP_PROXY, NO_PROXY};
/// Drop-in replacement for `tokio_tungstenite::connect_async` that routes
/// the underlying TCP connection through `HTTPS_PROXY` / `HTTP_PROXY`
/// (with `NO_PROXY` exclusions) when those env vars are set, and — for direct
/// (non-proxied) connections — pins DNS to `pinned_addrs`.
///
/// `pinned_addrs` are the addresses the SSRF guard already resolved and
/// validated for this URL (see `validate_websocket_url_for_ssrf`). Connecting
/// straight to them, rather than letting `connect_async` re-resolve the host,
/// closes the DNS-rebinding window between the check and the connect: a rebinder
/// cannot answer a public IP at validation time and an internal one here. When
/// `pinned_addrs` is empty (IP-literal host, or the SSRF guard opted out via
/// `ALLOW_PRIVATE_WEBSOCKET_URLS`) there is nothing to pin and we fall back to
/// `connect_async`, keeping the behaviour for those cases unchanged.
///
/// When a proxy applies, the proxy itself resolves the target host, so DNS
/// rebinding at this hop is not the worker's concern and `pinned_addrs` is
/// unused for that path.
pub async fn connect_async_with_proxy<R>(
request: R,
pinned_addrs: &[SocketAddr],
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response), WsError>
where
R: IntoClientRequest + Unpin,
{
let request = request.into_client_request()?;
let uri = request.uri().clone();
let scheme = uri.scheme_str().unwrap_or_default().to_ascii_lowercase();
let host = uri
.host()
.ok_or(WsError::Url(UrlError::NoHostName))?
.to_string();
let port = uri
.port_u16()
.or_else(|| match scheme.as_str() {
"wss" => Some(443),
"ws" => Some(80),
_ => None,
})
.ok_or(WsError::Url(UrlError::UnsupportedUrlScheme))?;
let proxy = if HTTPS_PROXY.is_none() && HTTP_PROXY.is_none() {
None
} else {
proxy_url_for(&scheme, &host).and_then(|raw| parse_proxy_target(&raw))
};
if let Some(proxy) = proxy {
tracing::debug!(
"Connecting to WebSocket {}:{} through HTTP proxy {}:{}",
host,
port,
proxy.host,
proxy.port,
);
let socket = http_connect_tunnel(&proxy, &host, port)
.await
.map_err(WsError::Io)?;
return client_async_tls_with_config(request, socket, None, None).await;
}
// Direct connection. Nothing to pin (IP literal or SSRF guard opted out):
// preserve the original resolve-and-connect path.
if pinned_addrs.is_empty() {
return connect_async(request).await;
}
// Pin to a validated address so this connect targets the same IP the SSRF
// guard checked. Try each in order (e.g. IPv6 then IPv4) until one connects.
let mut last_err: Option<io::Error> = None;
for addr in pinned_addrs {
match TcpStream::connect(addr).await {
Ok(socket) => {
return client_async_tls_with_config(request, socket, None, None).await;
}
Err(e) => last_err = Some(e),
}
}
Err(WsError::Io(last_err.unwrap_or_else(|| {
io::Error::new(
io::ErrorKind::AddrNotAvailable,
"no pinned address to connect",
)
})))
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProxyTarget {
host: String,
port: u16,
/// Base64-encoded `user:pass` from URL userinfo, ready to drop into
/// the `Proxy-Authorization: Basic …` header value.
basic_auth: Option<String>,
}
/// Resolve the proxy URL string to use for outbound `(scheme, host)`.
///
/// `wss://`/`https://` reads `HTTPS_PROXY`, `ws://`/`http://` reads
/// `HTTP_PROXY`. `NO_PROXY` short-circuits to `None`. The env-var
/// snapshots come from `windmill-common` so they share a single source
/// of truth with the worker's `PROXY_ENVS`.
fn proxy_url_for(scheme: &str, host: &str) -> Option<String> {
if let Some(no_proxy) = NO_PROXY.as_deref() {
if matches_no_proxy(host, no_proxy) {
return None;
}
}
let primary = if scheme.eq_ignore_ascii_case("wss") || scheme.eq_ignore_ascii_case("https") {
HTTPS_PROXY.as_deref()
} else {
HTTP_PROXY.as_deref()
};
primary
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
/// Match a host against a `NO_PROXY` value (comma-separated list).
///
/// Supports the conventional rules used by `curl`/`reqwest`:
/// - `*` matches everything
/// - exact host match
/// - bare-domain entry (`example.com`) matches `example.com` and any
/// subdomain (`foo.example.com`)
/// - leading-dot entry (`.example.com`) is normalised to the bare form
/// (matches `example.com` and any subdomain), to match what `reqwest`
/// and most ops folks expect
/// - any `:port` suffix on entries is ignored
///
/// CIDR/IP-range matches are intentionally not supported.
fn matches_no_proxy(host: &str, no_proxy: &str) -> bool {
let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
if host.is_empty() {
return false;
}
for raw in no_proxy.split(',') {
let entry = raw.trim().to_ascii_lowercase();
if entry.is_empty() {
continue;
}
if entry == "*" {
return true;
}
let entry = entry.split(':').next().unwrap_or(&entry);
let entry = entry.trim_end_matches('.');
let bare = entry.trim_start_matches('.');
if bare.is_empty() {
continue;
}
if host == bare {
return true;
}
if host.ends_with(&format!(".{}", bare)) {
return true;
}
}
false
}
/// Parse a proxy URL string into host/port and optional pre-encoded basic
/// auth. Accepts `host`, `host:port`, `scheme://host[:port]`, with an
/// optional `user[:pass]@` userinfo prefix. The scheme is used only to
/// pick a default port (`https` → 443, anything else → 80).
fn parse_proxy_target(raw: &str) -> Option<ProxyTarget> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
// `url::Url::parse` requires an explicit scheme — prepend `http://`
// when the user passed a bare `host[:port]`.
let prepended = if raw.contains("://") {
std::borrow::Cow::Borrowed(raw)
} else {
std::borrow::Cow::Owned(format!("http://{raw}"))
};
let url = Url::parse(&prepended).ok()?;
// `host_str()` keeps brackets around IPv6 literals; strip them so
// `TcpStream::connect((host, port))` resolves the address correctly.
let host = url
.host_str()?
.trim_start_matches('[')
.trim_end_matches(']');
if host.is_empty() {
return None;
}
let host = host.to_string();
let port =
url.port_or_known_default()
.unwrap_or(if url.scheme().eq_ignore_ascii_case("https") {
443
} else {
80
});
let basic_auth = match (url.username(), url.password()) {
("", None) => None,
(user, pass) => {
let creds = match pass {
Some(p) => format!("{user}:{p}"),
None => user.to_string(),
};
Some(BASE64_STANDARD.encode(creds))
}
};
Some(ProxyTarget { host, port, basic_auth })
}
/// Open a TCP connection to `proxy` and ask it to tunnel to
/// `(target_host, target_port)` via HTTP CONNECT. Returns the raw socket
/// once the proxy has acknowledged with a 2xx response — subsequent bytes
/// belong to the tunneled connection.
async fn http_connect_tunnel(
proxy: &ProxyTarget,
target_host: &str,
target_port: u16,
) -> io::Result<TcpStream> {
let mut stream = TcpStream::connect((proxy.host.as_str(), proxy.port)).await?;
let host_header = format!("{}:{}", target_host, target_port);
let mut req = format!("CONNECT {h} HTTP/1.1\r\nHost: {h}\r\n", h = host_header,);
if let Some(ref auth) = proxy.basic_auth {
req.push_str("Proxy-Authorization: Basic ");
req.push_str(auth);
req.push_str("\r\n");
}
req.push_str("Proxy-Connection: keep-alive\r\n\r\n");
stream.write_all(req.as_bytes()).await?;
stream.flush().await?;
let mut reader = BufReader::new(stream);
let mut status_line = String::new();
let n = reader.read_line(&mut status_line).await?;
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"HTTP proxy closed connection before sending CONNECT response",
));
}
let status_ok = status_line
.split_whitespace()
.nth(1)
.map(|s| s == "200")
.unwrap_or(false);
loop {
let mut line = String::new();
let n = reader.read_line(&mut line).await?;
if n == 0 || line == "\r\n" || line == "\n" {
break;
}
}
if !status_ok {
return Err(io::Error::new(
io::ErrorKind::Other,
format!(
"HTTP proxy CONNECT to {} rejected: {}",
host_header,
status_line.trim_end()
),
));
}
// A conforming proxy stays silent after the CONNECT response until the
// client speaks. If our read buffer is non-empty, the proxy spoke
// first — handing the raw socket to TLS would silently drop those
// bytes and break the handshake.
if !reader.buffer().is_empty() {
return Err(io::Error::new(
io::ErrorKind::Other,
"HTTP proxy sent unexpected bytes after CONNECT response",
));
}
Ok(reader.into_inner())
}
#[cfg(test)]
mod tests {
//! The single live test (`http_connect_tunnel_…_unwraps_stream`) drives
//! a real `TcpListener` masquerading as a proxy and verifies both the
//! on-the-wire CONNECT request and that the returned `TcpStream`
//! actually carries tunneled bytes. The other proxy-URL and NO_PROXY
//! checks are kept under `#[ignore]` for manual debugging — they cover
//! logic that's mostly delegated to `url::Url::parse` and trivial
//! string matching, so re-running them on every CI build is low ROI.
use super::*;
#[test]
#[ignore = "covered by upstream `url::Url::parse`; run manually with `--ignored` if changed"]
fn parse_proxy_target_shapes_and_ipv6_and_basic_auth() {
let p = parse_proxy_target("http://outbound.eps.apple.com:80").unwrap();
assert_eq!(p.host, "outbound.eps.apple.com");
assert_eq!(p.port, 80);
let p = parse_proxy_target("https://proxy.internal").unwrap();
assert_eq!(p.port, 443);
let p = parse_proxy_target("proxy.internal:3128").unwrap();
assert_eq!(p.port, 3128);
let p = parse_proxy_target("http://alice:s3cret@proxy.lan:8080").unwrap();
// base64("alice:s3cret") = YWxpY2U6czNjcmV0
assert_eq!(p.basic_auth.as_deref(), Some("YWxpY2U6czNjcmV0"));
let p = parse_proxy_target("http://[::1]:3128").unwrap();
assert_eq!(p.host, "::1");
assert_eq!(p.port, 3128);
assert!(parse_proxy_target("").is_none());
assert!(parse_proxy_target("http://").is_none());
}
#[test]
#[ignore = "trivial string matching; run manually with `--ignored` if rules change"]
fn no_proxy_matching_rules() {
assert!(matches_no_proxy("example.com", "*"));
assert!(matches_no_proxy("example.com", "example.com"));
assert!(matches_no_proxy("api.example.com", "example.com"));
assert!(matches_no_proxy("api.example.com", ".example.com"));
assert!(matches_no_proxy("example.com", ".example.com"));
assert!(matches_no_proxy("example.com", "example.com:8080"));
assert!(matches_no_proxy("API.Example.COM", "example.com"));
assert!(!matches_no_proxy("notexample.com", "example.com"));
assert!(!matches_no_proxy("slack.com", "example.com,internal.lan"));
}
/// Spin up a one-shot TCP listener acting as an HTTP proxy.
/// Reads the CONNECT request, asserts on it via `validate`, then
/// either replies `200 Connection Established` or the supplied
/// `respond` string. Echoes any further client bytes back so the test
/// can confirm the returned `TcpStream` carries the tunneled session.
async fn fake_proxy<F>(
respond: &'static str,
validate: F,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<Vec<u8>>)
where
F: FnOnce(&str) + Send + 'static,
{
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = String::new();
{
let mut reader = BufReader::new(&mut socket);
loop {
let mut line = String::new();
let n = reader.read_line(&mut line).await.unwrap();
request.push_str(&line);
if n == 0 || line == "\r\n" || line == "\n" {
break;
}
}
}
validate(&request);
socket.write_all(respond.as_bytes()).await.unwrap();
socket.flush().await.unwrap();
let mut tunneled = Vec::new();
socket.read_to_end(&mut tunneled).await.unwrap();
tunneled
});
(addr, handle)
}
#[tokio::test]
async fn http_connect_tunnel_sends_well_formed_request_and_unwraps_stream() {
use tokio::io::AsyncWriteExt;
let (addr, handle) = fake_proxy(
"HTTP/1.1 200 Connection Established\r\nProxy-Agent: test\r\n\r\n",
|req| {
assert!(
req.starts_with("CONNECT slack.com:443 HTTP/1.1\r\n"),
"got: {req:?}"
);
assert!(req.contains("Host: slack.com:443\r\n"));
assert!(!req.contains("Proxy-Authorization"));
},
)
.await;
let proxy =
ProxyTarget { host: addr.ip().to_string(), port: addr.port(), basic_auth: None };
let mut stream = http_connect_tunnel(&proxy, "slack.com", 443).await.unwrap();
stream.write_all(b"hello-tls").await.unwrap();
stream.shutdown().await.unwrap();
let tunneled = handle.await.unwrap();
assert_eq!(tunneled, b"hello-tls");
}
#[tokio::test]
#[ignore = "manual; fake-proxy edge cases (auth, error status). Run with `--ignored` if `http_connect_tunnel` changes."]
async fn http_connect_tunnel_forwards_basic_auth_and_surfaces_non_2xx() {
use tokio::io::AsyncWriteExt;
// Basic-auth header is forwarded.
let (addr, handle) = fake_proxy("HTTP/1.1 200 OK\r\n\r\n", |req| {
assert!(req.contains("Proxy-Authorization: Basic YWxpY2U6czNjcmV0\r\n"));
})
.await;
let proxy = ProxyTarget {
host: addr.ip().to_string(),
port: addr.port(),
basic_auth: Some("YWxpY2U6czNjcmV0".to_string()),
};
let mut stream = http_connect_tunnel(&proxy, "slack.com", 443).await.unwrap();
stream.shutdown().await.unwrap();
let _ = handle.await.unwrap();
// Non-2xx status surfaces as an error.
let (addr, handle) = fake_proxy(
"HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic\r\n\r\n",
|_| {},
)
.await;
let proxy =
ProxyTarget { host: addr.ip().to_string(), port: addr.port(), basic_auth: None };
let err = http_connect_tunnel(&proxy, "slack.com", 443)
.await
.unwrap_err();
assert!(err.to_string().contains("407"));
let _ = handle.await.unwrap();
}
}