@@ -16,7 +16,7 @@ use tracing::{debug, info, warn};
1616
1717use sentinel_agent_protocol:: {
1818 AgentHandler , AgentResponse , AgentServer , AuditMetadata , HeaderOp , RequestBodyChunkEvent ,
19- RequestHeadersEvent , ResponseHeadersEvent ,
19+ RequestHeadersEvent , ResponseBodyChunkEvent , ResponseHeadersEvent ,
2020} ;
2121
2222/// Command line arguments
@@ -68,6 +68,10 @@ struct Args {
6868 #[ arg( long, default_value = "1048576" , env = "WAF_MAX_BODY_SIZE" ) ]
6969 max_body_size : usize ,
7070
71+ /// Enable response body inspection (detect attacks in server responses)
72+ #[ arg( long, default_value = "false" , env = "WAF_RESPONSE_INSPECTION" ) ]
73+ response_inspection : bool ,
74+
7175 /// Enable verbose logging
7276 #[ arg( short, long, env = "WAF_VERBOSE" ) ]
7377 verbose : bool ,
@@ -133,6 +137,7 @@ pub struct WafConfig {
133137 pub exclude_paths : Vec < String > ,
134138 pub body_inspection_enabled : bool ,
135139 pub max_body_size : usize ,
140+ pub response_inspection_enabled : bool ,
136141}
137142
138143impl WafConfig {
@@ -154,6 +159,7 @@ impl WafConfig {
154159 exclude_paths,
155160 body_inspection_enabled : args. body_inspection ,
156161 max_body_size : args. max_body_size ,
162+ response_inspection_enabled : args. response_inspection ,
157163 }
158164 }
159165}
@@ -510,7 +516,7 @@ impl WafEngine {
510516 }
511517}
512518
513- /// Body accumulator for tracking in-progress request bodies
519+ /// Body accumulator for tracking in-progress bodies
514520#[ derive( Debug , Default ) ]
515521struct BodyAccumulator {
516522 data : Vec < u8 > ,
@@ -519,15 +525,17 @@ struct BodyAccumulator {
519525/// WAF agent
520526pub struct WafAgent {
521527 engine : WafEngine ,
522- pending_bodies : Arc < RwLock < HashMap < String , BodyAccumulator > > > ,
528+ pending_request_bodies : Arc < RwLock < HashMap < String , BodyAccumulator > > > ,
529+ pending_response_bodies : Arc < RwLock < HashMap < String , BodyAccumulator > > > ,
523530}
524531
525532impl WafAgent {
526533 pub fn new ( config : WafConfig ) -> Result < Self > {
527534 let engine = WafEngine :: new ( config) ?;
528535 Ok ( Self {
529536 engine,
530- pending_bodies : Arc :: new ( RwLock :: new ( HashMap :: new ( ) ) ) ,
537+ pending_request_bodies : Arc :: new ( RwLock :: new ( HashMap :: new ( ) ) ) ,
538+ pending_response_bodies : Arc :: new ( RwLock :: new ( HashMap :: new ( ) ) ) ,
531539 } )
532540 }
533541}
@@ -629,7 +637,7 @@ impl AgentHandler for WafAgent {
629637 } ;
630638
631639 // Accumulate chunk
632- let mut pending = self . pending_bodies . write ( ) . await ;
640+ let mut pending = self . pending_request_bodies . write ( ) . await ;
633641 let accumulator = pending
634642 . entry ( event. correlation_id . clone ( ) )
635643 . or_insert_with ( BodyAccumulator :: default) ;
@@ -724,6 +732,101 @@ impl AgentHandler for WafAgent {
724732
725733 AgentResponse :: default_allow ( )
726734 }
735+
736+ async fn on_response_body_chunk ( & self , event : ResponseBodyChunkEvent ) -> AgentResponse {
737+ // Skip if response inspection is disabled
738+ if !self . engine . config . response_inspection_enabled {
739+ return AgentResponse :: default_allow ( ) ;
740+ }
741+
742+ // Decode base64 chunk
743+ let chunk = match base64:: engine:: general_purpose:: STANDARD . decode ( & event. data ) {
744+ Ok ( data) => data,
745+ Err ( e) => {
746+ warn ! ( error = %e, "Failed to decode response body chunk" ) ;
747+ return AgentResponse :: default_allow ( ) ;
748+ }
749+ } ;
750+
751+ // Accumulate chunk
752+ let mut pending = self . pending_response_bodies . write ( ) . await ;
753+ let accumulator = pending
754+ . entry ( event. correlation_id . clone ( ) )
755+ . or_insert_with ( BodyAccumulator :: default) ;
756+
757+ // Check size limit before accumulating
758+ if accumulator. data . len ( ) + chunk. len ( ) > self . engine . config . max_body_size {
759+ debug ! (
760+ correlation_id = %event. correlation_id,
761+ current_size = accumulator. data. len( ) ,
762+ chunk_size = chunk. len( ) ,
763+ max_size = self . engine. config. max_body_size,
764+ "Response body exceeds max size, skipping inspection"
765+ ) ;
766+ pending. remove ( & event. correlation_id ) ;
767+ return AgentResponse :: default_allow ( ) ;
768+ }
769+
770+ accumulator. data . extend ( chunk) ;
771+
772+ // If this is the last chunk, inspect the full body
773+ if event. is_last {
774+ let body_data = pending. remove ( & event. correlation_id ) . unwrap ( ) ;
775+ let body_str = String :: from_utf8_lossy ( & body_data. data ) ;
776+
777+ debug ! (
778+ correlation_id = %event. correlation_id,
779+ body_size = body_data. data. len( ) ,
780+ "Inspecting response body"
781+ ) ;
782+
783+ let detections = self . engine . check ( & body_str, "response_body" ) ;
784+
785+ if detections. is_empty ( ) {
786+ return AgentResponse :: default_allow ( ) ;
787+ }
788+
789+ // Log detections
790+ for detection in & detections {
791+ warn ! (
792+ rule_id = detection. rule_id,
793+ rule_name = %detection. rule_name,
794+ attack_type = %detection. attack_type,
795+ location = %detection. location,
796+ matched = %detection. matched_value,
797+ "WAF detection in response body"
798+ ) ;
799+ }
800+
801+ let rule_ids: Vec < String > = detections. iter ( ) . map ( |d| d. rule_id . to_string ( ) ) . collect ( ) ;
802+
803+ // For response bodies, we can only log/audit - blocking would require
804+ // dropping the response which may not be desirable. We add headers to
805+ // indicate detection.
806+ info ! (
807+ detections = detections. len( ) ,
808+ first_rule = detections. first( ) . map( |d| d. rule_id) . unwrap_or( 0 ) ,
809+ "WAF detection in response (logged)"
810+ ) ;
811+
812+ return AgentResponse :: default_allow ( )
813+ . add_response_header ( HeaderOp :: Set {
814+ name : "X-WAF-Response-Detected" . to_string ( ) ,
815+ value : rule_ids. join ( "," ) ,
816+ } )
817+ . with_audit ( AuditMetadata {
818+ tags : vec ! [
819+ "waf" . to_string( ) ,
820+ "detected" . to_string( ) ,
821+ "response_body" . to_string( ) ,
822+ ] ,
823+ rule_ids,
824+ ..Default :: default ( )
825+ } ) ;
826+ }
827+
828+ AgentResponse :: default_allow ( )
829+ }
727830}
728831
729832#[ tokio:: main]
@@ -755,6 +858,7 @@ async fn main() -> Result<()> {
755858 command_injection = config. command_injection_enabled,
756859 block_mode = config. block_mode,
757860 body_inspection = config. body_inspection_enabled,
861+ response_inspection = config. response_inspection_enabled,
758862 max_body_size = config. max_body_size,
759863 "Configuration loaded"
760864 ) ;
@@ -786,6 +890,7 @@ mod tests {
786890 exclude_paths : vec ! [ "/health" . to_string( ) ] ,
787891 body_inspection_enabled : true ,
788892 max_body_size : 1048576 , // 1MB
893+ response_inspection_enabled : true ,
789894 } ;
790895 WafEngine :: new ( config) . unwrap ( )
791896 }
@@ -973,6 +1078,7 @@ mod tests {
9731078 exclude_paths : vec ! [ ] ,
9741079 body_inspection_enabled : false ,
9751080 max_body_size : 1024 ,
1081+ response_inspection_enabled : false ,
9761082 } ;
9771083 let engine = WafEngine :: new ( config) . unwrap ( ) ;
9781084
@@ -981,4 +1087,42 @@ mod tests {
9811087 let detections = engine. check ( body, "body" ) ;
9821088 assert ! ( !detections. is_empty( ) ) ; // Engine still detects, agent would skip
9831089 }
1090+
1091+ #[ test]
1092+ fn test_response_body_xss_detection ( ) {
1093+ let engine = test_engine ( ) ;
1094+
1095+ // Response containing reflected XSS
1096+ let response = r#"<html><body>Welcome <script>alert('xss')</script></body></html>"# ;
1097+ let detections = engine. check ( response, "response_body" ) ;
1098+ assert ! ( !detections. is_empty( ) ) ;
1099+ assert_eq ! ( detections[ 0 ] . attack_type, AttackType :: Xss ) ;
1100+
1101+ // Response with event handler XSS
1102+ let response = r#"<div onclick=alert(1)>Click me</div>"# ;
1103+ let detections = engine. check ( response, "response_body" ) ;
1104+ assert ! ( !detections. is_empty( ) ) ;
1105+ assert_eq ! ( detections[ 0 ] . attack_type, AttackType :: Xss ) ;
1106+
1107+ // Clean response
1108+ let response = r#"{"status": "ok", "message": "User created successfully"}"# ;
1109+ let detections = engine. check ( response, "response_body" ) ;
1110+ assert ! ( detections. is_empty( ) ) ;
1111+ }
1112+
1113+ #[ test]
1114+ fn test_response_body_error_leakage ( ) {
1115+ let engine = test_engine ( ) ;
1116+
1117+ // Response leaking path traversal in error
1118+ let response = "File not found: /etc/passwd" ;
1119+ let detections = engine. check ( response, "response_body" ) ;
1120+ assert ! ( !detections. is_empty( ) ) ;
1121+ assert_eq ! ( detections[ 0 ] . attack_type, AttackType :: PathTraversal ) ;
1122+
1123+ // Response leaking command output
1124+ let response = "Error executing: /bin/bash -c 'whoami'" ;
1125+ let detections = engine. check ( response, "response_body" ) ;
1126+ assert ! ( !detections. is_empty( ) ) ;
1127+ }
9841128}
0 commit comments