@@ -35,6 +35,11 @@ pub const HTTPJAIL_HEADER: &str = "HTTPJAIL";
3535pub const HTTPJAIL_HEADER_VALUE : & str = "true" ;
3636pub 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
3944pub fn create_connect_403_response ( ) -> & ' static [ u8 ] {
4045 b"HTTP/1.1 403 Forbidden\r \n Content-Type: text/plain\r \n Content-Length: 27\r \n \r \n Request blocked by httpjail"
@@ -166,6 +171,7 @@ static HTTPS_CLIENT: OnceLock<
166171pub 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+
367392pub 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
374398impl 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+
487512async 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
512529async 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
522538pub 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