@@ -569,14 +569,20 @@ where
569569 // on this host:port. Re-check it against the config that actually
570570 // matched: the opt-in is per-endpoint, and one endpoint enabling it
571571 // must not loosen parsing for the others.
572+ // Check `req.target`, not `route_target`: redaction percent-decodes any
573+ // segment holding a credential placeholder and re-inserts the redacted
574+ // form without re-encoding, so a `%2F` sharing that segment becomes a
575+ // literal `/` and would escape this check.
572576 if !config. allow_encoded_slash
573- && crate :: l7:: path:: canonical_path_has_encoded_slash ( & route_target )
577+ && crate :: l7:: path:: canonical_path_has_encoded_slash ( & req . target )
574578 {
579+ let detail = "request-target contains an encoded '/' (%2F) which is not allowed on this endpoint" ;
580+ emit_parse_rejection ( ctx, detail, engine_type_for_protocol ( config. protocol ) ) ;
575581 crate :: l7:: rest:: RestProvider :: default ( )
576582 . deny_with_redacted_target (
577583 & req,
578584 & ctx. policy_name ,
579- "request-target contains an encoded '/' (%2F) which is not allowed on this endpoint" ,
585+ detail ,
580586 client,
581587 None ,
582588 Some ( crate :: l7:: rest:: DenyResponseContext :: from_l7_context ( ctx) ) ,
@@ -6184,7 +6190,11 @@ network_policies:
61846190 . clone_engine_for_tunnel ( engine. current_generation ( ) )
61856191 . unwrap ( ) ;
61866192 let configs = encoded_slash_scoping_configs ( ) ;
6187- let ctx = encoded_slash_scoping_ctx ( ) ;
6193+ let ( activity_tx, mut activity_rx) = tokio:: sync:: mpsc:: channel ( 1 ) ;
6194+ let ctx = L7EvalContext {
6195+ activity_tx : Some ( activity_tx) ,
6196+ ..encoded_slash_scoping_ctx ( )
6197+ } ;
61886198
61896199 let ( mut app, mut relay_client) = tokio:: io:: duplex ( 8192 ) ;
61906200 let ( mut relay_upstream, mut upstream) = tokio:: io:: duplex ( 8192 ) ;
@@ -6217,6 +6227,85 @@ network_policies:
62176227 "denial must name the encoded-slash reason: {response}"
62186228 ) ;
62196229
6230+ let mut upstream_bytes = [ 0u8 ; 16 ] ;
6231+ let result = tokio:: time:: timeout (
6232+ std:: time:: Duration :: from_millis ( 100 ) ,
6233+ upstream. read ( & mut upstream_bytes) ,
6234+ )
6235+ . await ;
6236+ assert ! (
6237+ matches!( result, Err ( _) | Ok ( Ok ( 0 ) ) ) ,
6238+ "request must not reach upstream"
6239+ ) ;
6240+ let activity = tokio:: time:: timeout ( std:: time:: Duration :: from_secs ( 1 ) , activity_rx. recv ( ) )
6241+ . await
6242+ . expect ( "parse rejection activity should be emitted" )
6243+ . expect ( "activity channel should remain open" ) ;
6244+ assert ! ( activity. denied) ;
6245+ assert_eq ! ( activity. deny_group, "l7_parse_rejection" ) ;
6246+
6247+ drop ( app) ;
6248+ tokio:: time:: timeout ( std:: time:: Duration :: from_secs ( 1 ) , relay)
6249+ . await
6250+ . expect ( "relay should finish" )
6251+ . unwrap ( )
6252+ . unwrap ( ) ;
6253+ }
6254+
6255+ /// Credential redaction percent-decodes any segment holding a placeholder
6256+ /// and re-inserts the redacted form without re-encoding it. A `%2F` sharing
6257+ /// that segment therefore becomes a literal `/` in the redacted target, so
6258+ /// the scoping check must read the canonical target rather than the
6259+ /// redacted one — otherwise a placeholder is enough to smuggle an encoded
6260+ /// slash past an endpoint that never opted in.
6261+ #[ tokio:: test]
6262+ async fn route_selected_encoded_slash_check_survives_credential_redaction ( ) {
6263+ let engine = OpaEngine :: from_strings ( TEST_POLICY , ENCODED_SLASH_SCOPING_POLICY ) . unwrap ( ) ;
6264+ let tunnel_engine = engine
6265+ . clone_engine_for_tunnel ( engine. current_generation ( ) )
6266+ . unwrap ( ) ;
6267+ let configs = encoded_slash_scoping_configs ( ) ;
6268+ let ( child_env, resolver) = SecretResolver :: from_provider_env (
6269+ std:: iter:: once ( ( "TOKEN" . to_string ( ) , "real-token" . to_string ( ) ) ) . collect ( ) ,
6270+ ) ;
6271+ let placeholder = child_env. get ( "TOKEN" ) . expect ( "placeholder env" ) . clone ( ) ;
6272+ let ctx = L7EvalContext {
6273+ secret_resolver : resolver. map ( Arc :: new) ,
6274+ ..encoded_slash_scoping_ctx ( )
6275+ } ;
6276+
6277+ let ( mut app, mut relay_client) = tokio:: io:: duplex ( 8192 ) ;
6278+ let ( mut relay_upstream, mut upstream) = tokio:: io:: duplex ( 8192 ) ;
6279+ let relay = tokio:: spawn ( async move {
6280+ relay_with_route_selection (
6281+ & configs,
6282+ tunnel_engine,
6283+ & mut relay_client,
6284+ & mut relay_upstream,
6285+ & ctx,
6286+ )
6287+ . await
6288+ } ) ;
6289+
6290+ // Placeholder and encoded slash in the same segment, on the endpoint
6291+ // that did NOT opt into encoded slashes.
6292+ let request = format ! (
6293+ "GET /admin/{placeholder}%2Fx HTTP/1.1\r \n Host: gateway.example.test\r \n Connection: close\r \n \r \n "
6294+ ) ;
6295+ app. write_all ( request. as_bytes ( ) ) . await . unwrap ( ) ;
6296+
6297+ let mut response = [ 0u8 ; 1024 ] ;
6298+ let n = tokio:: time:: timeout ( std:: time:: Duration :: from_secs ( 1 ) , app. read ( & mut response) )
6299+ . await
6300+ . expect ( "denial should reach client" )
6301+ . unwrap ( ) ;
6302+ let response = String :: from_utf8_lossy ( & response[ ..n] ) ;
6303+ assert ! ( response. contains( "403 Forbidden" ) , "{response}" ) ;
6304+ assert ! (
6305+ response. contains( "not allowed on this endpoint" ) ,
6306+ "redaction must not hide the encoded slash: {response}"
6307+ ) ;
6308+
62206309 let mut upstream_bytes = [ 0u8 ; 16 ] ;
62216310 let result = tokio:: time:: timeout (
62226311 std:: time:: Duration :: from_millis ( 100 ) ,
0 commit comments