Skip to content

Commit 605e8ef

Browse files
committed
http_proxy: Add the allowlisting proxy server
Final piece of the crate: the in-process HTTP/HTTPS proxy server that enforces an `Allowlist`. It speaks HTTP CONNECT for HTTPS tunnels and forward proxying for plain HTTP, vets resolved addresses against loopback/private/link-local ranges to prevent DNS-rebinding past the sandbox, pins each connection to the destination approved for its first request (so later keep-alive requests can't escape the policy decision), optionally chains through the `UpstreamProxy`, and bounds header sizes, connection counts, and connect/handshake waits since its sole client is untrusted model-driven code running inside the editor process. Includes end-to-end tests covering allowed/denied CONNECT and HTTP forward, IP-literal handling, DNS-rebinding denial, and upstream chaining. Still has no callers; wired into the agent terminal sandbox in later PRs. Release Notes: - N/A
1 parent ab50118 commit 605e8ef

7 files changed

Lines changed: 1982 additions & 13 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,7 @@ human_bytes = "0.4.1"
606606
html5ever = "0.27.0"
607607
http = "1.1"
608608
http-body = "1.0"
609+
httparse = "1.10"
609610
idna = "1.0"
610611
ignore = "0.4.22"
611612
image = "0.25.1"

crates/http_proxy/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ path = "src/http_proxy.rs"
1313

1414
[dependencies]
1515
anyhow.workspace = true
16+
base64.workspace = true
17+
futures.workspace = true
18+
httparse.workspace = true
1619
idna.workspace = true
20+
log.workspace = true
1721
percent-encoding.workspace = true
1822
proxyvars.workspace = true
1923
thiserror.workspace = true
Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,55 @@
1-
//! Hostname-allowlisting primitives for confining sandboxed network access.
1+
//! In-process HTTP/HTTPS proxy that enforces a hostname allowlist.
22
//!
3-
//! This crate grows over a short stack of PRs:
3+
//! Spawned per terminal command from the parent process. The sandbox is
4+
//! configured to permit network only to this proxy's port; everything the
5+
//! sandboxed command tries to reach the network for has to come through here.
46
//!
5-
//! - [`allowlist`]: the policy types ([`HostPattern`], [`Allowlist`]) that
6-
//! decide which hosts a sandboxed command may reach.
7-
//! - [`UpstreamProxy`]: parsing an upstream HTTP proxy from the environment
8-
//! (`HTTPS_PROXY` / `NO_PROXY` etc.) to chain through.
9-
//! - the proxy server itself (next): an in-process HTTP/HTTPS proxy that
10-
//! enforces an [`Allowlist`] and is the only network egress a sandboxed
11-
//! command is permitted.
7+
//! The proxy:
8+
//!
9+
//! - Speaks HTTP CONNECT for HTTPS tunnels and HTTP forward proxying for
10+
//! plain HTTP. Other protocols cannot reach it (the seatbelt rule limits
11+
//! the sandboxed process to this one TCP destination, and this proxy only
12+
//! speaks HTTP).
13+
//! - Checks the destination hostname against an allowlist of exact hostnames
14+
//! and leading-`*.` subdomain wildcards. Unless the allowlist allows any
15+
//! host, IP-literal targets are denied, and hostnames whose DNS resolves
16+
//! only into loopback / private / link-local space are denied too
17+
//! (DNS-rebinding protection — the proxy runs outside the sandbox, so it
18+
//! must not reopen the local network the Seatbelt rule closed off).
19+
//! - Pins each TCP connection to the destination approved for its first
20+
//! request: directly (to the vetted resolved addresses) or via a CONNECT
21+
//! tunnel through an optional upstream HTTP proxy from the parent's
22+
//! environment (`HTTPS_PROXY` / `HTTP_PROXY`), honoring `NO_PROXY`. Plain
23+
//! HTTP is also tunneled when chaining, so keep-alive requests after the
24+
//! first can never be routed to a different host by the upstream.
25+
//! - Reports per-connection events (allowed, denied, completed) over an
26+
//! mpsc supplied by the caller.
27+
//!
28+
//! ## Trust assumptions
29+
//!
30+
//! The proxy's sole client is model-driven code running inside the sandbox —
31+
//! exactly the party the sandbox distrusts — and the proxy itself runs inside
32+
//! the editor process. It therefore caps request header sizes and concurrent
33+
//! connections, and bounds connect/handshake waits with timeouts, so a
34+
//! malicious command can't exhaust the editor's memory, threads, or file
35+
//! descriptors through it. Bandwidth is deliberately not capped; the
36+
//! command's lifetime bounds it.
37+
//!
38+
//! ## "No proxy here" principle
39+
//!
40+
//! The agent and tools running inside the sandbox should not need to know
41+
//! that a proxy is in front of them. The only response code the proxy
42+
//! synthesizes itself is `511 Network Authentication Required`, used solely
43+
//! for policy denials (with `Via:` and `Proxy-Status:` headers and a
44+
//! plain-text body explaining the policy decision). Other failure modes
45+
//! (upstream connection failure, malformed input from the client, etc.) are
46+
//! handled by silently closing the connection — same behavior the client
47+
//! would see from a direct network failure, no proxy fingerprint.
1248
1349
mod allowlist;
1450
mod proxy;
1551

1652
pub use allowlist::{Allowlist, HostPattern, HostPatternError};
17-
pub use proxy::UpstreamProxy;
53+
pub use proxy::{
54+
DenyReason, ProxyConfig, ProxyEvent, ProxyHandle, RequestMethod, RequestOutcome, UpstreamProxy,
55+
};

crates/http_proxy/src/proxy.rs

Lines changed: 280 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,284 @@
1-
//! The proxy module. For now it holds only the upstream-proxy configuration
2-
//! type; the proxy server (listener, connection handling) lands in a later
3-
//! PR.
1+
//! The proxy itself: listener, connection handlers, upstream chaining.
2+
//!
3+
//! All synchronous, thread-per-connection. `ProxyHandle::spawn` binds a
4+
//! `std::net::TcpListener` on `127.0.0.1:0` and returns once the listener
5+
//! is bound and the listener thread has been spawned. Drop the handle to
6+
//! shut everything down — the listener thread stops accepting new
7+
//! connections; in-flight connection threads finish on their own when
8+
//! either side closes.
9+
//!
10+
//! See the crate-level docs for trust assumptions and the "no proxy here"
11+
//! principle.
412
13+
mod connection;
514
mod upstream;
615

16+
use crate::allowlist::Allowlist;
17+
use anyhow::{Context, Result};
18+
use futures::channel::mpsc;
19+
use std::net::{Ipv4Addr, TcpListener, TcpStream};
20+
use std::sync::Arc;
21+
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
22+
use std::thread;
23+
24+
/// Cap on concurrently handled connections. Each connection costs the
25+
/// editor process two threads and two pump buffers; the cap keeps a
26+
/// runaway (or malicious) sandboxed command from exhausting the editor's
27+
/// thread/fd budget. Well above what parallel package managers open.
28+
const MAX_CONCURRENT_CONNECTIONS: usize = 256;
29+
730
pub use upstream::UpstreamProxy;
31+
32+
/// Configuration for spawning a proxy.
33+
#[derive(Debug, Clone)]
34+
pub struct ProxyConfig {
35+
/// Hosts the proxy will allow to be reached.
36+
pub allowlist: Allowlist,
37+
/// Optional upstream HTTP proxy to chain through, with `NO_PROXY`-style
38+
/// bypasses for hosts that should connect direct.
39+
pub upstream: Option<UpstreamProxy>,
40+
/// Where the proxy reports per-connection events. Use
41+
/// [`mpsc::unbounded`] so connection threads (which are sync) never
42+
/// block on send. The receiver is async-friendly so `gpui` / `tokio`
43+
/// callers can poll it from their executor of choice.
44+
pub events: mpsc::UnboundedSender<ProxyEvent>,
45+
}
46+
47+
/// A request method seen by the proxy.
48+
///
49+
/// Either a CONNECT (HTTPS tunnel) or an HTTP forward request.
50+
#[derive(Debug, Clone, PartialEq, Eq)]
51+
pub enum RequestMethod {
52+
Connect,
53+
Http(String),
54+
}
55+
56+
impl RequestMethod {
57+
pub fn as_str(&self) -> &str {
58+
match self {
59+
RequestMethod::Connect => "CONNECT",
60+
RequestMethod::Http(method) => method.as_str(),
61+
}
62+
}
63+
}
64+
65+
/// Outcome of a single connection's policy decision.
66+
#[derive(Debug, Clone)]
67+
pub enum RequestOutcome {
68+
Allowed,
69+
Denied { reason: DenyReason },
70+
}
71+
72+
/// Why an attempted connection was denied.
73+
#[derive(Debug, Clone, PartialEq, Eq)]
74+
pub enum DenyReason {
75+
/// Hostname (in punycode form on the wire) wasn't in the allowlist.
76+
HostNotInAllowlist { host: String },
77+
/// CONNECT or HTTP request targeted an IP literal. Denied unless the
78+
/// allowlist allows any host.
79+
IpLiteralRejected { target: String },
80+
/// The hostname resolved only to loopback / private / link-local
81+
/// addresses, which the sandbox policy never reaches via the allowlist
82+
/// (DNS-rebinding protection). Not applied when the allowlist allows
83+
/// any host.
84+
ResolvedToForbiddenIp { host: String },
85+
}
86+
87+
impl DenyReason {
88+
pub(crate) fn proxy_status_error(&self) -> &'static str {
89+
match self {
90+
DenyReason::HostNotInAllowlist { .. } => "destination_ip_prohibited",
91+
DenyReason::IpLiteralRejected { .. } => "destination_ip_prohibited",
92+
DenyReason::ResolvedToForbiddenIp { .. } => "destination_ip_prohibited",
93+
}
94+
}
95+
96+
pub(crate) fn human_explanation(&self) -> String {
97+
match self {
98+
DenyReason::HostNotInAllowlist { host } => {
99+
format!("host '{host}' is not in this conversation's network allowlist")
100+
}
101+
DenyReason::IpLiteralRejected { target } => format!(
102+
"target '{target}' is an IP literal; only hostnames are permitted by sandbox policy"
103+
),
104+
DenyReason::ResolvedToForbiddenIp { host } => format!(
105+
"host '{host}' resolves only to loopback/private/link-local addresses, \
106+
which sandbox policy blocks"
107+
),
108+
}
109+
}
110+
}
111+
112+
/// Events emitted by the proxy as it handles connections.
113+
#[derive(Debug, Clone)]
114+
pub enum ProxyEvent {
115+
/// Sent once after the listener is bound. Always the first event for
116+
/// a given proxy instance.
117+
Ready { port: u16 },
118+
119+
/// Emitted at policy-decision time, before bytes flow to the upstream.
120+
RequestAttempt {
121+
host: String,
122+
port: u16,
123+
method: RequestMethod,
124+
outcome: RequestOutcome,
125+
},
126+
127+
/// Emitted after an `Allowed` connection finishes. Carries throughput
128+
/// totals for diagnostics. Not emitted for denied connections.
129+
RequestCompleted {
130+
host: String,
131+
port: u16,
132+
method: RequestMethod,
133+
bytes_to_remote: u64,
134+
bytes_from_remote: u64,
135+
duration_ms: u64,
136+
},
137+
}
138+
139+
/// Handle to a running proxy. Drop to stop the listener; in-flight
140+
/// connection threads finish on their own as soon as either side closes.
141+
pub struct ProxyHandle {
142+
port: u16,
143+
/// Listener thread sees this flip to `true` after `accept` returns and
144+
/// then exits.
145+
shutdown: Arc<AtomicBool>,
146+
/// Joined on drop to make shutdown deterministic in tests; ignored if
147+
/// the listener has already exited.
148+
listener_thread: Option<thread::JoinHandle<()>>,
149+
}
150+
151+
impl ProxyHandle {
152+
/// Spawns the proxy: binds a listener on `127.0.0.1:0`, spawns the
153+
/// listener thread, sends a `Ready` event, and returns. The returned
154+
/// port is what callers should use for `HTTPS_PROXY`/`HTTP_PROXY` env
155+
/// vars and for the seatbelt rule narrowing `localhost:<port>`.
156+
pub fn spawn(config: ProxyConfig) -> Result<ProxyHandle> {
157+
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
158+
.context("failed to bind proxy listener on 127.0.0.1:0")?;
159+
let port = listener
160+
.local_addr()
161+
.context("failed to read proxy local addr")?
162+
.port();
163+
164+
// Inform the parent the proxy is ready before starting the accept
165+
// loop. Send is fire-and-forget on an unbounded channel — never
166+
// blocks, never errors meaningfully.
167+
let _ = config.events.unbounded_send(ProxyEvent::Ready { port });
168+
169+
let shutdown = Arc::new(AtomicBool::new(false));
170+
let runtime_state = Arc::new(RuntimeState {
171+
allowlist: config.allowlist,
172+
upstream: config.upstream,
173+
events: config.events,
174+
active_connections: AtomicUsize::new(0),
175+
});
176+
177+
let listener_thread = thread::Builder::new()
178+
.name("http-proxy-listener".to_string())
179+
// Listener thread does almost nothing on its stack — accept,
180+
// spawn, loop. 128 KiB is plenty.
181+
.stack_size(128 * 1024)
182+
.spawn({
183+
let shutdown = shutdown.clone();
184+
move || run_listener(listener, runtime_state, shutdown)
185+
})
186+
.context("failed to spawn proxy listener thread")?;
187+
188+
Ok(ProxyHandle {
189+
port,
190+
shutdown,
191+
listener_thread: Some(listener_thread),
192+
})
193+
}
194+
195+
/// The bound port. Stable for the lifetime of this handle.
196+
pub fn port(&self) -> u16 {
197+
self.port
198+
}
199+
}
200+
201+
impl Drop for ProxyHandle {
202+
fn drop(&mut self) {
203+
self.shutdown.store(true, Ordering::SeqCst);
204+
// The listener is blocked in `accept()`. Waking it up cleanly via
205+
// a flag alone isn't possible with `std::net::TcpListener` — there's
206+
// no way to interrupt the syscall. Connect to ourselves: the
207+
// listener wakes up, accepts the connection, sees the shutdown
208+
// flag, breaks the loop. The accepted connection's worker thread
209+
// will read the empty stream and exit too.
210+
let _ = TcpStream::connect((Ipv4Addr::LOCALHOST, self.port));
211+
212+
if let Some(thread) = self.listener_thread.take() {
213+
// Give the listener a chance to clean up. A join error means the
214+
// listener thread panicked; there's nothing to recover, but it
215+
// shouldn't pass unnoticed.
216+
if thread.join().is_err() {
217+
log::warn!("[http_proxy] listener thread panicked");
218+
}
219+
}
220+
}
221+
}
222+
223+
/// State shared across all connection threads for a single proxy instance.
224+
pub(crate) struct RuntimeState {
225+
pub(crate) allowlist: Allowlist,
226+
pub(crate) upstream: Option<UpstreamProxy>,
227+
pub(crate) events: mpsc::UnboundedSender<ProxyEvent>,
228+
active_connections: AtomicUsize,
229+
}
230+
231+
/// Decrements the active-connection count when a connection thread finishes
232+
/// (normally or by panic).
233+
struct ConnectionSlot(Arc<RuntimeState>);
234+
235+
impl Drop for ConnectionSlot {
236+
fn drop(&mut self) {
237+
self.0.active_connections.fetch_sub(1, Ordering::SeqCst);
238+
}
239+
}
240+
241+
fn run_listener(listener: TcpListener, state: Arc<RuntimeState>, shutdown: Arc<AtomicBool>) {
242+
for stream in listener.incoming() {
243+
if shutdown.load(Ordering::SeqCst) {
244+
log::debug!("[http_proxy] listener stopping (shutdown signaled)");
245+
break;
246+
}
247+
match stream {
248+
Ok(stream) => {
249+
let previous = state.active_connections.fetch_add(1, Ordering::SeqCst);
250+
if previous >= MAX_CONCURRENT_CONNECTIONS {
251+
state.active_connections.fetch_sub(1, Ordering::SeqCst);
252+
log::warn!(
253+
"[http_proxy] dropping connection: {MAX_CONCURRENT_CONNECTIONS} \
254+
connections already active"
255+
);
256+
drop(stream);
257+
continue;
258+
}
259+
let slot = ConnectionSlot(state.clone());
260+
let state = state.clone();
261+
let result = thread::Builder::new()
262+
.name("http-proxy-conn".to_string())
263+
// Connection workers do bidir copy with a 64 KiB buffer
264+
// and a few syscall stack frames. 128 KiB is plenty.
265+
.stack_size(128 * 1024)
266+
.spawn(move || {
267+
let _slot = slot;
268+
if let Err(e) = connection::handle(stream, state) {
269+
log::debug!("[http_proxy] connection handler error: {e}");
270+
}
271+
});
272+
if let Err(e) = result {
273+
log::warn!("[http_proxy] failed to spawn connection thread: {e}");
274+
}
275+
}
276+
Err(e) => {
277+
// EMFILE / per-process fd exhaustion is the realistic
278+
// failure here. Log and keep going — accept errors are
279+
// usually transient.
280+
log::warn!("[http_proxy] accept failed: {e}");
281+
}
282+
}
283+
}
284+
}

0 commit comments

Comments
 (0)