1115 lines · 39.6 KB · Rust
RawDownload
//! Per-connection logic: parse the first request, decide allowed/denied,//! either pump bytes through to the upstream or close (with an explanatory//! 511 for policy denials).//!//! Both CONNECT and HTTP forward go through here. After the policy decision,//! the TCP connection is pinned to the approved destination — directly, or//! via a CONNECT tunnel through the upstream proxy — and everything becomes//! opaque byte pumping. We don't parse anything else (chunked encoding,//! keep-alive HTTP requests after the first, etc.); because the whole//! connection can only reach the one approved host, later requests the//! client sends on it cannot escape the policy decision. Per-TCP-connection//! event granularity, by design. use crate::pinned_host::{PinnedHost, PinnedHostError};use crate::proxy::{    DenyReason, ProxyEvent, RequestMethod, RequestOutcome, RuntimeState, UpstreamProxy,};use anyhow::{Context, Result, anyhow, bail};use base64::Engine as _;use std::io::{Read, Write};use std::net::{IpAddr, Shutdown, SocketAddr, TcpStream, ToSocketAddrs as _};#[cfg(unix)]use std::os::unix::net::UnixStream;use std::sync::Arc;use std::thread;use std::time::{Duration, Instant};use url::Url; /// Buffer size for each direction of bidir copy. 64 KiB is the sweet spot/// for most networks — large enough to keep the pipe full, small enough/// not to balloon memory under many concurrent connections.const PUMP_BUFFER_SIZE: usize = 64 * 1024; /// Cap on request/response header bytes. The proxy runs inside the editor/// process and its sole client is model-driven code — exactly the party the/// sandbox distrusts — so an unbounded header read would let a malicious/// command balloon the editor's memory. 64 KiB is far beyond what real HTTP/// clients send.const MAX_HEADER_BYTES: usize = 64 * 1024; /// How long to wait for the client's request headers. Pooled connections/// that never send a request get closed; well-behaved clients retry on a/// fresh connection. Cleared before the pump phase so long-lived idle/// tunnels (long polls, slow downloads) are unaffected.const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(60); /// Timeout for outbound TCP connects (direct or to the upstream proxy),/// so a black-holed destination doesn't pin a connection thread for the/// OS default (~75s or more).const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); /// How long to wait for the upstream proxy's CONNECT response.const UPSTREAM_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); /// Top-level entry from the listener thread. Owns the client connection/// and the runtime state; emits events; never returns errors that escape/// the connection (those are logged at the listener level).pub(crate) enum ClientStream {    Tcp(TcpStream),    #[cfg(unix)]    Unix(UnixStream),} impl ClientStream {    fn try_clone(&self) -> std::io::Result<Self> {        match self {            Self::Tcp(stream) => stream.try_clone().map(Self::Tcp),            #[cfg(unix)]            Self::Unix(stream) => stream.try_clone().map(Self::Unix),        }    }     fn set_read_timeout(&self, duration: Option<Duration>) -> std::io::Result<()> {        match self {            Self::Tcp(stream) => stream.set_read_timeout(duration),            #[cfg(unix)]            Self::Unix(stream) => stream.set_read_timeout(duration),        }    }} impl Read for ClientStream {    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {        match self {            Self::Tcp(stream) => stream.read(buf),            #[cfg(unix)]            Self::Unix(stream) => stream.read(buf),        }    }} impl Write for ClientStream {    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {        match self {            Self::Tcp(stream) => stream.write(buf),            #[cfg(unix)]            Self::Unix(stream) => stream.write(buf),        }    }     fn flush(&mut self) -> std::io::Result<()> {        match self {            Self::Tcp(stream) => stream.flush(),            #[cfg(unix)]            Self::Unix(stream) => stream.flush(),        }    }} trait StreamIo: Read + Write + Send + 'static {    fn shutdown(&self, how: Shutdown) -> std::io::Result<()>;} impl StreamIo for ClientStream {    fn shutdown(&self, how: Shutdown) -> std::io::Result<()> {        match self {            Self::Tcp(stream) => stream.shutdown(how),            #[cfg(unix)]            Self::Unix(stream) => stream.shutdown(how),        }    }} impl StreamIo for TcpStream {    fn shutdown(&self, how: Shutdown) -> std::io::Result<()> {        TcpStream::shutdown(self, how)    }} pub(crate) fn handle(client: ClientStream, state: Arc<RuntimeState>) -> Result<()> {    if let ClientStream::Tcp(stream) = &client        && let Err(error) = stream.set_nodelay(true)    {        log::debug!("[http_proxy] failed to set TCP_NODELAY on client socket: {error}");    }     let mut client = client;    if let Err(error) = client.set_read_timeout(Some(HEADER_READ_TIMEOUT)) {        log::debug!("[http_proxy] failed to set client header read timeout: {error}");    }     // Read until end-of-headers (`\r\n\r\n`). The first request determines    // the destination for this entire TCP connection.    let (header_buf, header_end) = read_request_headers(&mut client)?;     // The header timeout must not apply to the pump phase. (Socket options    // are shared with the `try_clone` handles used by the pump.)    if let Err(error) = client.set_read_timeout(None) {        log::debug!("[http_proxy] failed to clear client read timeout: {error}");    }     let request = ParsedRequest::parse(&header_buf[..header_end])?;     // Bytes after the request headers — the start of an HTTP request body    // for forwarding cases, or (for unusually eager clients) bytes pipelined    // ahead of the CONNECT response. Replayed to the upstream either way.    let leftover_body = header_buf[header_end..].to_vec();     match request {        ParsedRequest::Connect { host, port } => {            handle_connect(client, host, port, leftover_body, state)        }        ParsedRequest::Http {            method,            host,            port,            request_bytes,        } => handle_http_forward(            client,            method,            host,            port,            request_bytes,            leftover_body,            state,        ),    }} /// Reads request headers (until `\r\n\r\n`) from the client. Returns the/// full buffer (which may include some bytes after the headers) and the/// offset where the headers ended. Capped at [`MAX_HEADER_BYTES`].fn read_request_headers(client: &mut ClientStream) -> Result<(Vec<u8>, usize)> {    let mut buf = Vec::with_capacity(4096);    let mut tmp = [0u8; 4096];    let mut searched = 0usize;    loop {        let n = client.read(&mut tmp)?;        if n == 0 {            bail!("client closed before sending complete request headers");        }        buf.extend_from_slice(&tmp[..n]);        // Only scan the new bytes (plus 3 bytes of overlap for a delimiter        // straddling the read boundary), keeping the search linear overall.        let scan_start = searched.saturating_sub(3);        if let Some(end) = find_double_crlf(&buf[scan_start..]) {            return Ok((buf, scan_start + end));        }        searched = buf.len();        if buf.len() > MAX_HEADER_BYTES {            bail!("request headers exceed {MAX_HEADER_BYTES} bytes");        }    }} fn find_double_crlf(buf: &[u8]) -> Option<usize> {    buf.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4)} /// Parsed first request from the client.enum ParsedRequest {    Connect {        host: String,        port: u16,    },    Http {        method: String,        host: String,        port: u16,        /// Request bytes (line + headers) to forward to the origin.        /// Absolute-form requests are rewritten to origin-form (see        /// [`build_origin_form_request`]); origin-form requests pass        /// through verbatim.        request_bytes: Vec<u8>,    },} impl ParsedRequest {    fn parse(headers: &[u8]) -> Result<Self> {        let mut header_storage = [httparse::EMPTY_HEADER; 64];        let mut req = httparse::Request::new(&mut header_storage);        let status = req.parse(headers).context("malformed HTTP request")?;        if !status.is_complete() {            bail!("incomplete HTTP request after \\r\\n\\r\\n boundary");        }         let method = req            .method            .ok_or_else(|| anyhow!("missing HTTP method"))?            .to_string();        let target = req.path.ok_or_else(|| anyhow!("missing request target"))?;         if method.eq_ignore_ascii_case("CONNECT") {            let (host, port) = parse_authority_form(target)?;            Ok(ParsedRequest::Connect { host, port })        } else if target.starts_with("http://") || target.starts_with("https://") {            let (host, port, url) = parse_absolute_form_target(target)?;            let request_bytes = build_origin_form_request(&method, &url, req.version, req.headers);            Ok(ParsedRequest::Http {                method,                host,                port,                request_bytes,            })        } else {            // Origin-form request: destination comes from the Host: header,            // and the bytes are already in the form an origin server expects,            // so forward them verbatim.            let host_hdr = req                .headers                .iter()                .find(|h| h.name.eq_ignore_ascii_case("host"))                .ok_or_else(|| anyhow!("origin-form request missing Host: header"))?;            let value =                std::str::from_utf8(host_hdr.value).context("Host: header is not valid UTF-8")?;            let (host, port) = parse_host_header(value)?;            Ok(ParsedRequest::Http {                method,                host,                port,                request_bytes: headers.to_vec(),            })        }    }} /// Parse `host:port` (CONNECT authority-form). Port is required, per RFC 9112/// §3.2.3.////// The port is split off manually rather than via `Url::parse`, because `Url`/// elides scheme-default ports — `host:80` parsed with an `http://` prefix/// becomes indistinguishable from a missing port, and `CONNECT host:80` is/// legitimate (e.g. `ws://` through a proxy). `Url` still canonicalizes the/// host part (bracketed IPv6, IDN-to-punycode, lowercasing).fn parse_authority_form(input: &str) -> Result<(String, u16)> {    let (host_part, port_part) = input        .rsplit_once(':')        .ok_or_else(|| anyhow!("CONNECT target '{input}' must include a port"))?;    let port: u16 = port_part        .parse()        .with_context(|| format!("CONNECT target '{input}' has an invalid port"))?;    let parsed = Url::parse(&format!("http://{host_part}"))        .with_context(|| format!("parsing CONNECT target '{input}'"))?;    let host = parsed        .host_str()        .ok_or_else(|| anyhow!("CONNECT target '{input}' has no host"))?        .to_string();    Ok((host, port))} /// Parse an absolute-form HTTP request target like `http://foo.com/path`./// Returns the parsed URL too, for the origin-form rewrite.fn parse_absolute_form_target(target: &str) -> Result<(String, u16, Url)> {    let parsed =        Url::parse(target).with_context(|| format!("parsing absolute-form target '{target}'"))?;    let host = parsed        .host_str()        .ok_or_else(|| anyhow!("absolute-form target '{target}' has no host"))?        .to_string();    // `port_or_known_default` covers `http://foo.com` (→ 80) without forcing    // the agent to spell it.    let port = parsed        .port_or_known_default()        .ok_or_else(|| anyhow!("absolute-form target '{target}' has no port"))?;    Ok((host, port, parsed))} /// Rewrite an absolute-form proxy request into origin-form for the origin/// server.////// RFC 9112 §3.2.2 requires origin servers to accept absolute-form, but many/// real servers and frameworks don't handle it; every production proxy/// rewrites, and so do we. The `Host` header is regenerated from the URL/// (per the same section, a proxy must use the URI host and ignore a/// mismatched `Host`), and `Proxy-*` headers — which are addressed to us and/// may carry credentials (`Proxy-Authorization`) — are stripped rather than/// leaked to the origin.fn build_origin_form_request(    method: &str,    url: &Url,    version: Option<u8>,    headers: &[httparse::Header],) -> Vec<u8> {    let mut target = url.path().to_string();    if let Some(query) = url.query() {        target.push('?');        target.push_str(query);    }    // `host_str` keeps brackets on IPv6 literals, which is what the Host    // header wants. `url.port()` is None for scheme-default ports, matching    // the convention of omitting them.    let host_value = match (url.host_str(), url.port()) {        (Some(host), Some(port)) => format!("{host}:{port}"),        (Some(host), None) => host.to_string(),        // Unreachable in practice: callers parsed the host already.        (None, _) => String::new(),    };    let minor_version = version.unwrap_or(1);     let mut out = Vec::with_capacity(256);    out.extend_from_slice(format!("{method} {target} HTTP/1.{minor_version}\r\n").as_bytes());    out.extend_from_slice(format!("Host: {host_value}\r\n").as_bytes());    for header in headers {        let name = header.name;        if name.eq_ignore_ascii_case("host")            || name                .get(.."proxy-".len())                .is_some_and(|prefix| prefix.eq_ignore_ascii_case("proxy-"))        {            continue;        }        out.extend_from_slice(name.as_bytes());        out.extend_from_slice(b": ");        out.extend_from_slice(header.value);        out.extend_from_slice(b"\r\n");    }    out.extend_from_slice(b"\r\n");    out} /// Parse a `Host:` header value into `(host, port)`. Default port is 80/// since this is only called for HTTP forward, never CONNECT.fn parse_host_header(value: &str) -> Result<(String, u16)> {    let value = value.trim();    if value.is_empty() {        bail!("empty Host header");    }    let parsed = Url::parse(&format!("http://{value}"))        .with_context(|| format!("parsing Host header '{value}'"))?;    let host = parsed        .host_str()        .ok_or_else(|| anyhow!("Host header '{value}' has no host"))?        .to_string();    let port = parsed.port().unwrap_or(80);    Ok((host, port))} /// Normalize a hostname for allowlist matching. Strips brackets from IPv6/// literals and a single trailing dot. Lowercasing happens inside the/// allowlist matcher.fn normalize_host(host: &str) -> String {    let stripped = host        .strip_prefix('[')        .and_then(|s| s.strip_suffix(']'))        .unwrap_or(host);    stripped.trim_end_matches('.').to_string()} /// Whether a hostname is an IP literal, per our policy.fn is_ip_literal(host: &str) -> bool {    let stripped = host        .strip_prefix('[')        .and_then(|s| s.strip_suffix(']'))        .unwrap_or(host);    stripped.parse::<IpAddr>().is_ok()} /// The policy check shared by CONNECT and HTTP forward: IP-literal targets/// and hosts outside the allowlist are denied. Both checks are skipped when/// the allowlist allows any host — that grant means unrestricted egress,/// including IP literals (matching the pre-allowlist `allow_network`/// behavior).fn policy_denial(host: &str, port: u16, state: &RuntimeState) -> Option<DenyReason> {    if state.allowlist.allows_any() {        return None;    }    if is_ip_literal(host) {        return Some(DenyReason::IpLiteralRejected {            target: format!("{host}:{port}"),        });    }    if !state.allowlist.allows(host) {        return Some(DenyReason::HostNotInAllowlist {            host: host.to_string(),        });    }    None} /// How an approved request will reach its destination.enum Route {    /// Connect directly to one of the addresses pinned when the host was    /// resolved and vetted.    Direct(PinnedHost),    /// Tunnel through the upstream proxy with a CONNECT handshake.    ViaUpstream(UpstreamProxy),} enum RouteFailure {    /// Policy denial — respond with 511.    Denied(DenyReason),    /// Network-level failure — close silently, per "no proxy here".    Error(anyhow::Error),} /// Decide how to reach `host:port`, resolving and vetting addresses for/// direct connections.////// Resolution happens here, in the unsandboxed editor process, so this is/// also where we keep allowlisted hostnames from smuggling the sandboxed/// command onto the local machine or local network: a hostname whose DNS/// points into loopback / private / link-local space (DNS rebinding) is/// denied, and the connection later uses the vetted addresses rather than/// re-resolving. The filter is skipped when the allowlist allows any host,/// since that grant means unrestricted egress. Upstream-proxied destinations/// aren't resolved locally at all — the upstream does its own resolution/// inside the user's trusted network.fn plan_route(host: &str, port: u16, state: &RuntimeState) -> Result<Route, RouteFailure> {    if let Some(upstream) = &state.upstream        && !upstream.bypasses(host, port)    {        return Ok(Route::ViaUpstream(upstream.clone()));    }     match PinnedHost::resolve_for_allowlist(host, port, &state.allowlist) {        Ok(pinned) => Ok(Route::Direct(pinned)),        Err(PinnedHostError::AllAddressesForbidden { host }) => {            Err(RouteFailure::Denied(DenyReason::ResolvedToForbiddenIp {                host,            }))        }        Err(error) => Err(RouteFailure::Error(anyhow!(error))),    }} fn handle_connect(    mut client: ClientStream,    host: String,    port: u16,    leftover_body: Vec<u8>,    state: Arc<RuntimeState>,) -> Result<()> {    let normalized = normalize_host(&host);     if let Some(reason) = policy_denial(&normalized, port, &state) {        return deny_request(            &mut client,            &state,            normalized,            port,            RequestMethod::Connect,            reason,        );    }     let route = match plan_route(&normalized, port, &state) {        Ok(route) => route,        Err(RouteFailure::Denied(reason)) => {            return deny_request(                &mut client,

Lines 1–500 of 1115.