Skip to content

Commit 22f90fd

Browse files
authored
fix: prevent infinite proxy loop with nonce-based detection (#85)
* Fix #84: Prevent infinite loop with nonce-based detection When httpjail in server mode receives a request targeting itself, it now detects and blocks the loop using a unique nonce per proxy instance. Each proxy instance: - Generates a random nonce on startup - Appends its nonce to Httpjail-Loop-Prevention header (using HTTP's native multi-value header support) - Checks incoming requests for its own nonce and blocks with 403 if found This approach: - Supports chaining multiple httpjail instances - Works across network topologies (reverse proxies, Docker, etc.) - Uses HTTP standard multi-value headers instead of CSV parsing Also refactored code for DRY: - Created ProxyContext struct to bundle rule_engine, cert_manager, loop_nonce - Reduced function signature duplication throughout codebase - Extracted wait_for_server() helper to tests/common for reuse Test: New test_server_mode_self_request_loop_prevention verifies the fix, completing in ~0.3s (vs timing out without the fix). * Add missing CertificateManager import for macOS The perform_tls_interception function uses CertificateManager::is_ca_trusted() on macOS but the import was missing from the main module (only present in tests). This caused compilation to fail on macOS CI. Added conditional import with #[cfg(target_os = "macos")] to match usage. * Address code review: make wait_for_server async and clean up test - Convert wait_for_server() to async using tokio::time and tokio::net::TcpStream - Update all callers (start_server, start_server_with_bind, and tests) to async - Clean up self_request_loop test: remove verbose comments and println - Use tracing::debug for test debugging output - Simplify test assertions to focus on essential behavior
1 parent e3fd9fb commit 22f90fd

File tree

5 files changed

+289
-153
lines changed

5 files changed

+289
-153
lines changed

src/proxy.rs

Lines changed: 116 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ pub const HTTPJAIL_HEADER: &str = "HTTPJAIL";
3535
pub const HTTPJAIL_HEADER_VALUE: &str = "true";
3636
pub const BLOCKED_MESSAGE: &str = "Request blocked by httpjail";
3737

38+
/// Header added to outgoing requests to detect loops (Issue #84)
39+
/// Contains comma-separated nonces of all httpjail instances in the proxy chain.
40+
/// If we see our own nonce in an incoming request, we're in a loop.
41+
pub const HTTPJAIL_LOOP_DETECTION_HEADER: &str = "Httpjail-Loop-Prevention";
42+
3843
/// Create a raw HTTP/1.1 403 Forbidden response for CONNECT tunnels
3944
pub fn create_connect_403_response() -> &'static [u8] {
4045
b"HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: 27\r\n\r\nRequest blocked by httpjail"
@@ -166,6 +171,7 @@ static HTTPS_CLIENT: OnceLock<
166171
pub fn prepare_upstream_request(
167172
req: Request<Incoming>,
168173
target_uri: Uri,
174+
loop_nonce: &str,
169175
) -> Request<BoxBody<Bytes, HyperError>> {
170176
let (mut parts, incoming_body) = req.into_parts();
171177

@@ -178,6 +184,16 @@ pub fn prepare_upstream_request(
178184
parts.headers.remove("proxy-authorization");
179185
parts.headers.remove("proxy-authenticate");
180186

187+
// SECURITY: Add our nonce to the loop detection header (Issue #84)
188+
// HTTP natively supports multiple values for the same header name (via append).
189+
// This allows chaining multiple httpjail instances while still detecting self-loops.
190+
// Each instance appends its nonce; if we see our own nonce in an incoming request, it's a loop.
191+
parts.headers.append(
192+
HTTPJAIL_LOOP_DETECTION_HEADER,
193+
hyper::header::HeaderValue::from_str(loop_nonce)
194+
.unwrap_or_else(|_| hyper::header::HeaderValue::from_static("invalid")),
195+
);
196+
181197
// SECURITY: Ensure the Host header matches the URI to prevent routing bypasses (Issue #57)
182198
// This prevents attacks where an attacker sends a request to one domain but sets
183199
// the Host header to another domain, potentially bypassing security controls in
@@ -364,11 +380,19 @@ async fn bind_listener(addr: std::net::SocketAddr) -> Result<TcpListener> {
364380
TcpListener::bind(addr).await.map_err(Into::into)
365381
}
366382

383+
/// Context passed to all proxy handlers - reduces argument duplication
384+
#[derive(Clone)]
385+
pub struct ProxyContext {
386+
pub rule_engine: Arc<RuleEngine>,
387+
pub cert_manager: Arc<CertificateManager>,
388+
/// Unique nonce for this proxy instance, used for loop detection (Issue #84)
389+
pub loop_nonce: Arc<String>,
390+
}
391+
367392
pub struct ProxyServer {
368393
http_bind: Option<std::net::SocketAddr>,
369394
https_bind: Option<std::net::SocketAddr>,
370-
rule_engine: Arc<RuleEngine>,
371-
cert_manager: Arc<CertificateManager>,
395+
context: ProxyContext,
372396
}
373397

374398
impl ProxyServer {
@@ -383,11 +407,23 @@ impl ProxyServer {
383407
let ca_cert_der = cert_manager.get_ca_cert_der();
384408
init_client_with_ca(ca_cert_der);
385409

410+
// Generate a unique nonce for loop detection (Issue #84)
411+
// Use 16 random hex characters for a reasonably short but collision-resistant ID
412+
let loop_nonce = {
413+
let random_u64: u64 = rand::random();
414+
format!("{:x}", random_u64)
415+
};
416+
417+
let context = ProxyContext {
418+
rule_engine: Arc::new(rule_engine),
419+
cert_manager: Arc::new(cert_manager),
420+
loop_nonce: Arc::new(loop_nonce),
421+
};
422+
386423
ProxyServer {
387424
http_bind,
388425
https_bind,
389-
rule_engine: Arc::new(rule_engine),
390-
cert_manager: Arc::new(cert_manager),
426+
context,
391427
}
392428
}
393429

@@ -403,35 +439,13 @@ impl ProxyServer {
403439
let http_port = http_listener.local_addr()?.port();
404440
info!("Starting HTTP proxy on port {}", http_port);
405441

406-
let rule_engine = Arc::clone(&self.rule_engine);
407-
let cert_manager = Arc::clone(&self.cert_manager);
408-
409442
// Start HTTP proxy task
410-
tokio::spawn(async move {
411-
loop {
412-
match http_listener.accept().await {
413-
Ok((stream, addr)) => {
414-
debug!("New HTTP connection from {}", addr);
415-
let rule_engine = Arc::clone(&rule_engine);
416-
let cert_manager = Arc::clone(&cert_manager);
417-
418-
tokio::spawn(async move {
419-
if let Err(e) =
420-
handle_http_connection(stream, rule_engine, cert_manager, addr)
421-
.await
422-
{
423-
error!("Error handling HTTP connection: {:?}", e);
424-
}
425-
});
426-
}
427-
Err(e) => {
428-
error!("Failed to accept HTTP connection: {}", e);
429-
}
430-
}
431-
}
432-
});
433-
434-
// IPv6-specific listener not required; IPv4 listener suffices for jail routing
443+
spawn_listener_task(
444+
http_listener,
445+
self.context.clone(),
446+
"HTTP",
447+
handle_http_connection,
448+
);
435449

436450
// Bind HTTPS listener
437451
let https_listener = if let Some(addr) = self.https_bind {
@@ -444,61 +458,64 @@ impl ProxyServer {
444458
let https_port = https_listener.local_addr()?.port();
445459
info!("Starting HTTPS proxy on port {}", https_port);
446460

447-
let rule_engine = Arc::clone(&self.rule_engine);
448-
let cert_manager = Arc::clone(&self.cert_manager);
449-
450461
// Start HTTPS proxy task
451-
tokio::spawn(async move {
452-
loop {
453-
match https_listener.accept().await {
454-
Ok((stream, addr)) => {
455-
debug!("New HTTPS connection from {}", addr);
456-
let rule_engine = Arc::clone(&rule_engine);
457-
let cert_manager = Arc::clone(&cert_manager);
458-
459-
tokio::spawn(async move {
460-
if let Err(e) =
461-
handle_https_connection(stream, rule_engine, cert_manager, addr)
462-
.await
463-
{
464-
error!("Error handling HTTPS connection: {:?}", e);
465-
}
466-
});
467-
}
468-
Err(e) => {
469-
error!("Failed to accept HTTPS connection: {}", e);
470-
}
471-
}
472-
}
473-
});
474-
475-
// IPv6-specific listener not required; IPv4 listener suffices for jail routing
462+
spawn_listener_task(
463+
https_listener,
464+
self.context.clone(),
465+
"HTTPS",
466+
handle_https_connection,
467+
);
476468

477469
Ok((http_port, https_port))
478470
}
479471

480472
/// Get the CA certificate for client trust
481473
#[allow(dead_code)]
482474
pub fn get_ca_cert_pem(&self) -> String {
483-
self.cert_manager.get_ca_cert_pem()
475+
self.context.cert_manager.get_ca_cert_pem()
484476
}
485477
}
486478

479+
/// Generic listener task spawner to avoid code duplication between HTTP and HTTPS
480+
fn spawn_listener_task<F, Fut>(
481+
listener: TcpListener,
482+
context: ProxyContext,
483+
protocol: &'static str,
484+
handler: F,
485+
) where
486+
F: Fn(TcpStream, ProxyContext, SocketAddr) -> Fut + Send + Sync + 'static,
487+
Fut: std::future::Future<Output = Result<()>> + Send + 'static,
488+
{
489+
let handler = Arc::new(handler);
490+
tokio::spawn(async move {
491+
loop {
492+
match listener.accept().await {
493+
Ok((stream, addr)) => {
494+
debug!("New {} connection from {}", protocol, addr);
495+
let context = context.clone();
496+
let handler = Arc::clone(&handler);
497+
498+
tokio::spawn(async move {
499+
if let Err(e) = handler(stream, context, addr).await {
500+
error!("Error handling {} connection: {:?}", protocol, e);
501+
}
502+
});
503+
}
504+
Err(e) => {
505+
error!("Failed to accept {} connection: {}", protocol, e);
506+
}
507+
}
508+
}
509+
});
510+
}
511+
487512
async fn handle_http_connection(
488513
stream: TcpStream,
489-
rule_engine: Arc<RuleEngine>,
490-
cert_manager: Arc<CertificateManager>,
514+
context: ProxyContext,
491515
remote_addr: SocketAddr,
492516
) -> Result<()> {
493517
let io = TokioIo::new(stream);
494-
let service = service_fn(move |req| {
495-
handle_http_request(
496-
req,
497-
Arc::clone(&rule_engine),
498-
Arc::clone(&cert_manager),
499-
remote_addr,
500-
)
501-
});
518+
let service = service_fn(move |req| handle_http_request(req, context.clone(), remote_addr));
502519

503520
http1::Builder::new()
504521
.preserve_header_case(true)
@@ -511,24 +528,41 @@ async fn handle_http_connection(
511528

512529
async fn handle_https_connection(
513530
stream: TcpStream,
514-
rule_engine: Arc<RuleEngine>,
515-
cert_manager: Arc<CertificateManager>,
531+
context: ProxyContext,
516532
remote_addr: SocketAddr,
517533
) -> Result<()> {
518534
// Delegate to the TLS-specific module
519-
crate::proxy_tls::handle_https_connection(stream, rule_engine, cert_manager, remote_addr).await
535+
crate::proxy_tls::handle_https_connection(stream, context, remote_addr).await
520536
}
521537

522538
pub async fn handle_http_request(
523539
req: Request<Incoming>,
524-
rule_engine: Arc<RuleEngine>,
525-
_cert_manager: Arc<CertificateManager>,
540+
context: ProxyContext,
526541
remote_addr: SocketAddr,
527542
) -> Result<Response<BoxBody<Bytes, HyperError>>, std::convert::Infallible> {
528543
let method = req.method().clone();
529544
let uri = req.uri().clone();
530545
let headers = req.headers().clone();
531546

547+
// SECURITY: Check for loop detection header (Issue #84)
548+
// HTTP supports multiple values for the same header name.
549+
// Each httpjail instance adds its nonce; if we see our own, it's a loop.
550+
let our_nonce = context.loop_nonce.as_str();
551+
for value in headers.get_all(HTTPJAIL_LOOP_DETECTION_HEADER).iter() {
552+
if let Ok(nonce) = value.to_str() {
553+
if nonce == our_nonce {
554+
debug!(
555+
"Loop detected: our nonce '{}' found in request to {}",
556+
nonce, uri
557+
);
558+
return create_forbidden_response(Some(
559+
"Loop detected: request already processed by this httpjail instance"
560+
.to_string(),
561+
));
562+
}
563+
}
564+
}
565+
532566
// Check if the URI already contains the full URL (proxy request)
533567
let full_url = if uri.scheme().is_some() && uri.authority().is_some() {
534568
// This is a proxy request with absolute URL (e.g., GET http://example.com/ HTTP/1.1)
@@ -551,7 +585,8 @@ pub async fn handle_http_request(
551585

552586
// Evaluate rules with method and requester IP
553587
let requester_ip = remote_addr.ip().to_string();
554-
let evaluation = rule_engine
588+
let evaluation = context
589+
.rule_engine
555590
.evaluate_with_context_and_ip(method, &full_url, &requester_ip)
556591
.await;
557592
match evaluation.action {
@@ -560,7 +595,8 @@ pub async fn handle_http_request(
560595
"Request allowed: {} (max_tx_bytes: {:?})",
561596
full_url, evaluation.max_tx_bytes
562597
);
563-
match proxy_request(req, &full_url, evaluation.max_tx_bytes).await {
598+
match proxy_request(req, &full_url, evaluation.max_tx_bytes, &context.loop_nonce).await
599+
{
564600
Ok(resp) => Ok(resp),
565601
Err(e) => {
566602
error!("Proxy error: {}", e);
@@ -579,12 +615,13 @@ async fn proxy_request(
579615
req: Request<Incoming>,
580616
full_url: &str,
581617
max_tx_bytes: Option<u64>,
618+
loop_nonce: &str,
582619
) -> Result<Response<BoxBody<Bytes, HyperError>>> {
583620
// Parse the target URL
584621
let target_uri = full_url.parse::<Uri>()?;
585622

586623
// Prepare request for upstream
587-
let prepared_req = prepare_upstream_request(req, target_uri.clone());
624+
let prepared_req = prepare_upstream_request(req, target_uri.clone(), loop_nonce);
588625

589626
// Apply byte limit to outgoing request if specified, converting to BoxBody
590627
let new_req = if let Some(max_bytes) = max_tx_bytes {

0 commit comments

Comments
 (0)