@@ -463,7 +463,16 @@ async fn handle_tcp_connection(
463463 & deny_reason,
464464 "connect" ,
465465 ) ;
466- respond ( & mut client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
466+ respond (
467+ & mut client,
468+ & build_json_error_response (
469+ 403 ,
470+ "Forbidden" ,
471+ "policy_denied" ,
472+ & format ! ( "CONNECT {host_lc}:{port} not permitted by policy" ) ,
473+ ) ,
474+ )
475+ . await ?;
467476 return Ok ( ( ) ) ;
468477 }
469478
@@ -518,7 +527,16 @@ async fn handle_tcp_connection(
518527 & reason,
519528 "ssrf" ,
520529 ) ;
521- respond ( & mut client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
530+ respond (
531+ & mut client,
532+ & build_json_error_response (
533+ 403 ,
534+ "Forbidden" ,
535+ "ssrf_denied" ,
536+ & format ! ( "CONNECT {host_lc}:{port} blocked: allowed_ips check failed" ) ,
537+ ) ,
538+ )
539+ . await ?;
522540 return Ok ( ( ) ) ;
523541 }
524542 } ,
@@ -553,7 +571,16 @@ async fn handle_tcp_connection(
553571 & reason,
554572 "ssrf" ,
555573 ) ;
556- respond ( & mut client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
574+ respond (
575+ & mut client,
576+ & build_json_error_response (
577+ 403 ,
578+ "Forbidden" ,
579+ "ssrf_denied" ,
580+ & format ! ( "CONNECT {host_lc}:{port} blocked: invalid allowed_ips in policy" ) ,
581+ ) ,
582+ )
583+ . await ?;
557584 return Ok ( ( ) ) ;
558585 }
559586 }
@@ -594,7 +621,16 @@ async fn handle_tcp_connection(
594621 & reason,
595622 "ssrf" ,
596623 ) ;
597- respond ( & mut client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
624+ respond (
625+ & mut client,
626+ & build_json_error_response (
627+ 403 ,
628+ "Forbidden" ,
629+ "ssrf_denied" ,
630+ & format ! ( "CONNECT {host_lc}:{port} blocked: internal address" ) ,
631+ ) ,
632+ )
633+ . await ?;
598634 return Ok ( ( ) ) ;
599635 }
600636 }
@@ -2071,7 +2107,16 @@ async fn handle_forward_proxy(
20712107 reason,
20722108 "forward" ,
20732109 ) ;
2074- respond ( client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
2110+ respond (
2111+ client,
2112+ & build_json_error_response (
2113+ 403 ,
2114+ "Forbidden" ,
2115+ "policy_denied" ,
2116+ & format ! ( "{method} {host_lc}:{port}{path} not permitted by policy" ) ,
2117+ ) ,
2118+ )
2119+ . await ?;
20752120 return Ok ( ( ) ) ;
20762121 }
20772122 } ;
@@ -2199,7 +2244,16 @@ async fn handle_forward_proxy(
21992244 & reason,
22002245 "forward-l7-deny" ,
22012246 ) ;
2202- respond ( client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
2247+ respond (
2248+ client,
2249+ & build_json_error_response (
2250+ 403 ,
2251+ "Forbidden" ,
2252+ "policy_denied" ,
2253+ & format ! ( "{method} {host_lc}:{port}{path} denied by L7 policy" ) ,
2254+ ) ,
2255+ )
2256+ . await ?;
22032257 return Ok ( ( ) ) ;
22042258 }
22052259 }
@@ -2254,7 +2308,16 @@ async fn handle_forward_proxy(
22542308 & reason,
22552309 "ssrf" ,
22562310 ) ;
2257- respond ( client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
2311+ respond (
2312+ client,
2313+ & build_json_error_response (
2314+ 403 ,
2315+ "Forbidden" ,
2316+ "ssrf_denied" ,
2317+ & format ! ( "{method} {host_lc}:{port} blocked: allowed_ips check failed" ) ,
2318+ ) ,
2319+ )
2320+ . await ?;
22582321 return Ok ( ( ) ) ;
22592322 }
22602323 } ,
@@ -2292,7 +2355,18 @@ async fn handle_forward_proxy(
22922355 & reason,
22932356 "ssrf" ,
22942357 ) ;
2295- respond ( client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
2358+ respond (
2359+ client,
2360+ & build_json_error_response (
2361+ 403 ,
2362+ "Forbidden" ,
2363+ "ssrf_denied" ,
2364+ & format ! (
2365+ "{method} {host_lc}:{port} blocked: invalid allowed_ips in policy"
2366+ ) ,
2367+ ) ,
2368+ )
2369+ . await ?;
22962370 return Ok ( ( ) ) ;
22972371 }
22982372 }
@@ -2334,7 +2408,16 @@ async fn handle_forward_proxy(
23342408 & reason,
23352409 "ssrf" ,
23362410 ) ;
2337- respond ( client, b"HTTP/1.1 403 Forbidden\r \n \r \n " ) . await ?;
2411+ respond (
2412+ client,
2413+ & build_json_error_response (
2414+ 403 ,
2415+ "Forbidden" ,
2416+ "ssrf_denied" ,
2417+ & format ! ( "{method} {host_lc}:{port} blocked: internal address" ) ,
2418+ ) ,
2419+ )
2420+ . await ?;
23382421 return Ok ( ( ) ) ;
23392422 }
23402423 }
@@ -2363,7 +2446,16 @@ async fn handle_forward_proxy(
23632446 ) )
23642447 . build ( ) ;
23652448 ocsf_emit ! ( event) ;
2366- respond ( client, b"HTTP/1.1 502 Bad Gateway\r \n \r \n " ) . await ?;
2449+ respond (
2450+ client,
2451+ & build_json_error_response (
2452+ 502 ,
2453+ "Bad Gateway" ,
2454+ "upstream_unreachable" ,
2455+ & format ! ( "connection to {host_lc}:{port} failed" ) ,
2456+ ) ,
2457+ )
2458+ . await ?;
23672459 return Ok ( ( ) ) ;
23682460 }
23692461 } ;
@@ -2402,7 +2494,16 @@ async fn handle_forward_proxy(
24022494 error = %e,
24032495 "credential injection failed in forward proxy"
24042496 ) ;
2405- respond ( client, b"HTTP/1.1 500 Internal Server Error\r \n \r \n " ) . await ?;
2497+ respond (
2498+ client,
2499+ & build_json_error_response (
2500+ 500 ,
2501+ "Internal Server Error" ,
2502+ "credential_injection_failed" ,
2503+ "unresolved credential placeholder in request" ,
2504+ ) ,
2505+ )
2506+ . await ?;
24062507 return Ok ( ( ) ) ;
24072508 }
24082509 } ;
@@ -2431,6 +2532,30 @@ async fn respond(client: &mut TcpStream, bytes: &[u8]) -> Result<()> {
24312532 Ok ( ( ) )
24322533}
24332534
2535+ /// Build an HTTP error response with a JSON body.
2536+ ///
2537+ /// Returns bytes ready to write to the client socket. The body is a JSON
2538+ /// object with `error` and `detail` fields, matching the format used by the
2539+ /// L7 deny path in `l7/rest.rs`.
2540+ fn build_json_error_response ( status : u16 , status_text : & str , error : & str , detail : & str ) -> Vec < u8 > {
2541+ let body = serde_json:: json!( {
2542+ "error" : error,
2543+ "detail" : detail,
2544+ } ) ;
2545+ let body_str = body. to_string ( ) ;
2546+ format ! (
2547+ "HTTP/1.1 {status} {status_text}\r \n \
2548+ Content-Type: application/json\r \n \
2549+ Content-Length: {}\r \n \
2550+ Connection: close\r \n \
2551+ \r \n \
2552+ {}",
2553+ body_str. len( ) ,
2554+ body_str,
2555+ )
2556+ . into_bytes ( )
2557+ }
2558+
24342559/// Check if a miette error represents a benign connection close.
24352560///
24362561/// TLS handshake EOF, missing `close_notify`, connection resets, and broken
@@ -3292,4 +3417,65 @@ mod tests {
32923417 let result = implicit_allowed_ips_for_ip_host ( "*.example.com" ) ;
32933418 assert ! ( result. is_empty( ) ) ;
32943419 }
3420+
3421+ // -- build_json_error_response --
3422+
3423+ #[ test]
3424+ fn test_json_error_response_403 ( ) {
3425+ let resp = build_json_error_response (
3426+ 403 ,
3427+ "Forbidden" ,
3428+ "policy_denied" ,
3429+ "CONNECT api.example.com:443 not permitted by policy" ,
3430+ ) ;
3431+ let resp_str = String :: from_utf8 ( resp) . unwrap ( ) ;
3432+
3433+ assert ! ( resp_str. starts_with( "HTTP/1.1 403 Forbidden\r \n " ) ) ;
3434+ assert ! ( resp_str. contains( "Content-Type: application/json\r \n " ) ) ;
3435+ assert ! ( resp_str. contains( "Connection: close\r \n " ) ) ;
3436+
3437+ // Extract body after \r\n\r\n
3438+ let body_start = resp_str. find ( "\r \n \r \n " ) . unwrap ( ) + 4 ;
3439+ let body: serde_json:: Value = serde_json:: from_str ( & resp_str[ body_start..] ) . unwrap ( ) ;
3440+ assert_eq ! ( body[ "error" ] , "policy_denied" ) ;
3441+ assert_eq ! (
3442+ body[ "detail" ] ,
3443+ "CONNECT api.example.com:443 not permitted by policy"
3444+ ) ;
3445+ }
3446+
3447+ #[ test]
3448+ fn test_json_error_response_502 ( ) {
3449+ let resp = build_json_error_response (
3450+ 502 ,
3451+ "Bad Gateway" ,
3452+ "upstream_unreachable" ,
3453+ "connection to api.example.com:443 failed" ,
3454+ ) ;
3455+ let resp_str = String :: from_utf8 ( resp) . unwrap ( ) ;
3456+
3457+ assert ! ( resp_str. starts_with( "HTTP/1.1 502 Bad Gateway\r \n " ) ) ;
3458+
3459+ let body_start = resp_str. find ( "\r \n \r \n " ) . unwrap ( ) + 4 ;
3460+ let body: serde_json:: Value = serde_json:: from_str ( & resp_str[ body_start..] ) . unwrap ( ) ;
3461+ assert_eq ! ( body[ "error" ] , "upstream_unreachable" ) ;
3462+ assert_eq ! ( body[ "detail" ] , "connection to api.example.com:443 failed" ) ;
3463+ }
3464+
3465+ #[ test]
3466+ fn test_json_error_response_content_length_matches ( ) {
3467+ let resp = build_json_error_response ( 403 , "Forbidden" , "test" , "detail" ) ;
3468+ let resp_str = String :: from_utf8 ( resp) . unwrap ( ) ;
3469+
3470+ // Extract Content-Length value
3471+ let cl_line = resp_str
3472+ . lines ( )
3473+ . find ( |l| l. starts_with ( "Content-Length:" ) )
3474+ . unwrap ( ) ;
3475+ let cl: usize = cl_line. split ( ": " ) . nth ( 1 ) . unwrap ( ) . trim ( ) . parse ( ) . unwrap ( ) ;
3476+
3477+ // Verify body length matches
3478+ let body_start = resp_str. find ( "\r \n \r \n " ) . unwrap ( ) + 4 ;
3479+ assert_eq ! ( resp_str[ body_start..] . len( ) , cl) ;
3480+ }
32953481}
0 commit comments