@@ -569,8 +569,12 @@ 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 {
575579 crate :: l7:: rest:: RestProvider :: default ( )
576580 . deny_with_redacted_target (
@@ -6236,6 +6240,79 @@ network_policies:
62366240 . unwrap ( ) ;
62376241 }
62386242
6243+ /// Credential redaction percent-decodes any segment holding a placeholder
6244+ /// and re-inserts the redacted form without re-encoding it. A `%2F` sharing
6245+ /// that segment therefore becomes a literal `/` in the redacted target, so
6246+ /// the scoping check must read the canonical target rather than the
6247+ /// redacted one — otherwise a placeholder is enough to smuggle an encoded
6248+ /// slash past an endpoint that never opted in.
6249+ #[ tokio:: test]
6250+ async fn route_selected_encoded_slash_check_survives_credential_redaction ( ) {
6251+ let engine = OpaEngine :: from_strings ( TEST_POLICY , ENCODED_SLASH_SCOPING_POLICY ) . unwrap ( ) ;
6252+ let tunnel_engine = engine
6253+ . clone_engine_for_tunnel ( engine. current_generation ( ) )
6254+ . unwrap ( ) ;
6255+ let configs = encoded_slash_scoping_configs ( ) ;
6256+ let ( child_env, resolver) = SecretResolver :: from_provider_env (
6257+ std:: iter:: once ( ( "TOKEN" . to_string ( ) , "real-token" . to_string ( ) ) ) . collect ( ) ,
6258+ ) ;
6259+ let placeholder = child_env. get ( "TOKEN" ) . expect ( "placeholder env" ) . clone ( ) ;
6260+ let ctx = L7EvalContext {
6261+ secret_resolver : resolver. map ( Arc :: new) ,
6262+ ..encoded_slash_scoping_ctx ( )
6263+ } ;
6264+
6265+ let ( mut app, mut relay_client) = tokio:: io:: duplex ( 8192 ) ;
6266+ let ( mut relay_upstream, mut upstream) = tokio:: io:: duplex ( 8192 ) ;
6267+ let relay = tokio:: spawn ( async move {
6268+ relay_with_route_selection (
6269+ & configs,
6270+ tunnel_engine,
6271+ & mut relay_client,
6272+ & mut relay_upstream,
6273+ & ctx,
6274+ )
6275+ . await
6276+ } ) ;
6277+
6278+ // Placeholder and encoded slash in the same segment, on the endpoint
6279+ // that did NOT opt into encoded slashes.
6280+ let request = format ! (
6281+ "GET /admin/{placeholder}%2Fx HTTP/1.1\r \n Host: gateway.example.test\r \n Connection: close\r \n \r \n "
6282+ ) ;
6283+ app. write_all ( request. as_bytes ( ) ) . await . unwrap ( ) ;
6284+
6285+ let mut response = [ 0u8 ; 1024 ] ;
6286+ let n = tokio:: time:: timeout ( std:: time:: Duration :: from_secs ( 1 ) , app. read ( & mut response) )
6287+ . await
6288+ . expect ( "denial should reach client" )
6289+ . unwrap ( ) ;
6290+ let response = String :: from_utf8_lossy ( & response[ ..n] ) ;
6291+ assert ! ( response. contains( "403 Forbidden" ) , "{response}" ) ;
6292+ assert ! (
6293+ response. contains( "not allowed on this endpoint" ) ,
6294+ "redaction must not hide the encoded slash: {response}"
6295+ ) ;
6296+
6297+ let mut upstream_bytes = [ 0u8 ; 16 ] ;
6298+ let result = tokio:: time:: timeout (
6299+ std:: time:: Duration :: from_millis ( 100 ) ,
6300+ upstream. read ( & mut upstream_bytes) ,
6301+ )
6302+ . await ;
6303+ assert ! (
6304+ matches!( result, Err ( _) | Ok ( Ok ( 0 ) ) ) ,
6305+ "request must not reach upstream"
6306+ ) ;
6307+
6308+ drop ( app) ;
6309+ tokio:: time:: timeout ( std:: time:: Duration :: from_secs ( 1 ) , relay)
6310+ . await
6311+ . expect ( "relay should finish" )
6312+ . unwrap ( )
6313+ . unwrap ( ) ;
6314+ }
6315+
62396316 /// The converse: tightening the scope must not break the endpoint that
62406317 /// legitimately opted in. A GitLab-style encoded slug still reaches the
62416318 /// upstream verbatim.
0 commit comments