@@ -464,7 +464,16 @@ async fn handle_tcp_connection(
464464 & deny_reason,
465465 "connect" ,
466466 ) ;
467- respond ( & mut client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
467+ respond (
468+ & mut client,
469+ & build_json_error_response (
470+ 403 ,
471+ "Forbidden" ,
472+ "policy_denied" ,
473+ & format ! ( "CONNECT {host_lc}:{port} not permitted by policy" ) ,
474+ ) ,
475+ )
476+ . await ?;
468477 return Ok ( ( ) ) ;
469478 }
470479
@@ -519,7 +528,16 @@ async fn handle_tcp_connection(
519528 & reason,
520529 "ssrf" ,
521530 ) ;
522- respond ( & mut client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
531+ respond (
532+ & mut client,
533+ & build_json_error_response (
534+ 403 ,
535+ "Forbidden" ,
536+ "ssrf_denied" ,
537+ & format ! ( "CONNECT {host_lc}:{port} blocked: allowed_ips check failed" ) ,
538+ ) ,
539+ )
540+ . await ?;
523541 return Ok ( ( ) ) ;
524542 }
525543 } ,
@@ -554,7 +572,16 @@ async fn handle_tcp_connection(
554572 & reason,
555573 "ssrf" ,
556574 ) ;
557- respond ( & mut client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
575+ respond (
576+ & mut client,
577+ & build_json_error_response (
578+ 403 ,
579+ "Forbidden" ,
580+ "ssrf_denied" ,
581+ & format ! ( "CONNECT {host_lc}:{port} blocked: invalid allowed_ips in policy" ) ,
582+ ) ,
583+ )
584+ . await ?;
558585 return Ok ( ( ) ) ;
559586 }
560587 }
@@ -595,7 +622,16 @@ async fn handle_tcp_connection(
595622 & reason,
596623 "ssrf" ,
597624 ) ;
598- respond ( & mut client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
625+ respond (
626+ & mut client,
627+ & build_json_error_response (
628+ 403 ,
629+ "Forbidden" ,
630+ "ssrf_denied" ,
631+ & format ! ( "CONNECT {host_lc}:{port} blocked: internal address" ) ,
632+ ) ,
633+ )
634+ . await ?;
599635 return Ok ( ( ) ) ;
600636 }
601637 }
@@ -2040,7 +2076,16 @@ async fn handle_forward_proxy(
20402076 reason,
20412077 "forward" ,
20422078 ) ;
2043- respond ( client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
2079+ respond (
2080+ client,
2081+ & build_json_error_response (
2082+ 403 ,
2083+ "Forbidden" ,
2084+ "policy_denied" ,
2085+ & format ! ( "{method} {host_lc}:{port}{path} not permitted by policy" ) ,
2086+ ) ,
2087+ )
2088+ . await ?;
20442089 return Ok ( ( ) ) ;
20452090 }
20462091 } ;
@@ -2168,7 +2213,16 @@ async fn handle_forward_proxy(
21682213 & reason,
21692214 "forward-l7-deny" ,
21702215 ) ;
2171- respond ( client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
2216+ respond (
2217+ client,
2218+ & build_json_error_response (
2219+ 403 ,
2220+ "Forbidden" ,
2221+ "policy_denied" ,
2222+ & format ! ( "{method} {host_lc}:{port}{path} denied by L7 policy" ) ,
2223+ ) ,
2224+ )
2225+ . await ?;
21722226 return Ok ( ( ) ) ;
21732227 }
21742228 }
@@ -2224,7 +2278,16 @@ async fn handle_forward_proxy(
22242278 & reason,
22252279 "ssrf" ,
22262280 ) ;
2227- respond ( client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
2281+ respond (
2282+ client,
2283+ & build_json_error_response (
2284+ 403 ,
2285+ "Forbidden" ,
2286+ "ssrf_denied" ,
2287+ & format ! ( "{method} {host_lc}:{port} blocked: allowed_ips check failed" ) ,
2288+ ) ,
2289+ )
2290+ . await ?;
22282291 return Ok ( ( ) ) ;
22292292 }
22302293 } ,
@@ -2263,7 +2326,18 @@ async fn handle_forward_proxy(
22632326 & reason,
22642327 "ssrf" ,
22652328 ) ;
2266- respond ( client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
2329+ respond (
2330+ client,
2331+ & build_json_error_response (
2332+ 403 ,
2333+ "Forbidden" ,
2334+ "ssrf_denied" ,
2335+ & format ! (
2336+ "{method} {host_lc}:{port} blocked: invalid allowed_ips in policy"
2337+ ) ,
2338+ ) ,
2339+ )
2340+ . await ?;
22672341 return Ok ( ( ) ) ;
22682342 }
22692343 }
@@ -2306,7 +2380,16 @@ async fn handle_forward_proxy(
23062380 & reason,
23072381 "ssrf" ,
23082382 ) ;
2309- respond ( client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
2383+ respond (
2384+ client,
2385+ & build_json_error_response (
2386+ 403 ,
2387+ "Forbidden" ,
2388+ "ssrf_denied" ,
2389+ & format ! ( "{method} {host_lc}:{port} blocked: internal address" ) ,
2390+ ) ,
2391+ )
2392+ . await ?;
23102393 return Ok ( ( ) ) ;
23112394 }
23122395 }
@@ -2335,7 +2418,16 @@ async fn handle_forward_proxy(
23352418 ) )
23362419 . build ( ) ;
23372420 ocsf_emit ! ( event) ;
2338- respond ( client, b"HTTP/1.1 502 Bad Gateway\r \n \r \n " ) . await ?;
2421+ respond (
2422+ client,
2423+ & build_json_error_response (
2424+ 502 ,
2425+ "Bad Gateway" ,
2426+ "upstream_unreachable" ,
2427+ & format ! ( "connection to {host_lc}:{port} failed" ) ,
2428+ ) ,
2429+ )
2430+ . await ?;
23392431 return Ok ( ( ) ) ;
23402432 }
23412433 } ;
@@ -2374,7 +2466,16 @@ async fn handle_forward_proxy(
23742466 error = %e,
23752467 "credential injection failed in forward proxy"
23762468 ) ;
2377- respond ( client, b"HTTP/1.1 500 Internal Server Error\r \n \r \n " ) . await ?;
2469+ respond (
2470+ client,
2471+ & build_json_error_response (
2472+ 500 ,
2473+ "Internal Server Error" ,
2474+ "credential_injection_failed" ,
2475+ "unresolved credential placeholder in request" ,
2476+ ) ,
2477+ )
2478+ . await ?;
23782479 return Ok ( ( ) ) ;
23792480 }
23802481 } ;
@@ -2403,6 +2504,30 @@ async fn respond(client: &mut TcpStream, bytes: &[u8]) -> Result<()> {
24032504 Ok ( ( ) )
24042505}
24052506
2507+ /// Build an HTTP error response with a JSON body.
2508+ ///
2509+ /// Returns bytes ready to write to the client socket. The body is a JSON
2510+ /// object with `error` and `detail` fields, matching the format used by the
2511+ /// L7 deny path in `l7/rest.rs`.
2512+ fn build_json_error_response ( status : u16 , status_text : & str , error : & str , detail : & str ) -> Vec < u8 > {
2513+ let body = serde_json:: json!( {
2514+ "error" : error,
2515+ "detail" : detail,
2516+ } ) ;
2517+ let body_str = body. to_string ( ) ;
2518+ format ! (
2519+ "HTTP/1.1 {status} {status_text}\r \n \
2520+ Content-Type: application/json\r \n \
2521+ Content-Length: {}\r \n \
2522+ Connection: close\r \n \
2523+ \r \n \
2524+ {}",
2525+ body_str. len( ) ,
2526+ body_str,
2527+ )
2528+ . into_bytes ( )
2529+ }
2530+
24062531/// Check if a miette error represents a benign connection close.
24072532///
24082533/// TLS handshake EOF, missing `close_notify`, connection resets, and broken
@@ -3355,4 +3480,65 @@ mod tests {
33553480 let result = implicit_allowed_ips_for_ip_host ( "*.example.com" ) ;
33563481 assert ! ( result. is_empty( ) ) ;
33573482 }
3483+
3484+ // -- build_json_error_response --
3485+
3486+ #[ test]
3487+ fn test_json_error_response_403 ( ) {
3488+ let resp = build_json_error_response (
3489+ 403 ,
3490+ "Forbidden" ,
3491+ "policy_denied" ,
3492+ "CONNECT api.example.com:443 not permitted by policy" ,
3493+ ) ;
3494+ let resp_str = String :: from_utf8 ( resp) . unwrap ( ) ;
3495+
3496+ assert ! ( resp_str. starts_with( "HTTP/1.1 403 Forbidden\r \n " ) ) ;
3497+ assert ! ( resp_str. contains( "Content-Type: application/json\r \n " ) ) ;
3498+ assert ! ( resp_str. contains( "Connection: close\r \n " ) ) ;
3499+
3500+ // Extract body after \r\n\r\n
3501+ let body_start = resp_str. find ( "\r \n \r \n " ) . unwrap ( ) + 4 ;
3502+ let body: serde_json:: Value = serde_json:: from_str ( & resp_str[ body_start..] ) . unwrap ( ) ;
3503+ assert_eq ! ( body[ "error" ] , "policy_denied" ) ;
3504+ assert_eq ! (
3505+ body[ "detail" ] ,
3506+ "CONNECT api.example.com:443 not permitted by policy"
3507+ ) ;
3508+ }
3509+
3510+ #[ test]
3511+ fn test_json_error_response_502 ( ) {
3512+ let resp = build_json_error_response (
3513+ 502 ,
3514+ "Bad Gateway" ,
3515+ "upstream_unreachable" ,
3516+ "connection to api.example.com:443 failed" ,
3517+ ) ;
3518+ let resp_str = String :: from_utf8 ( resp) . unwrap ( ) ;
3519+
3520+ assert ! ( resp_str. starts_with( "HTTP/1.1 502 Bad Gateway\r \n " ) ) ;
3521+
3522+ let body_start = resp_str. find ( "\r \n \r \n " ) . unwrap ( ) + 4 ;
3523+ let body: serde_json:: Value = serde_json:: from_str ( & resp_str[ body_start..] ) . unwrap ( ) ;
3524+ assert_eq ! ( body[ "error" ] , "upstream_unreachable" ) ;
3525+ assert_eq ! ( body[ "detail" ] , "connection to api.example.com:443 failed" ) ;
3526+ }
3527+
3528+ #[ test]
3529+ fn test_json_error_response_content_length_matches ( ) {
3530+ let resp = build_json_error_response ( 403 , "Forbidden" , "test" , "detail" ) ;
3531+ let resp_str = String :: from_utf8 ( resp) . unwrap ( ) ;
3532+
3533+ // Extract Content-Length value
3534+ let cl_line = resp_str
3535+ . lines ( )
3536+ . find ( |l| l. starts_with ( "Content-Length:" ) )
3537+ . unwrap ( ) ;
3538+ let cl: usize = cl_line. split ( ": " ) . nth ( 1 ) . unwrap ( ) . trim ( ) . parse ( ) . unwrap ( ) ;
3539+
3540+ // Verify body length matches
3541+ let body_start = resp_str. find ( "\r \n \r \n " ) . unwrap ( ) + 4 ;
3542+ assert_eq ! ( resp_str[ body_start..] . len( ) , cl) ;
3543+ }
33583544}
0 commit comments