@@ -564,6 +564,26 @@ where
564564 . await ?;
565565 return Ok ( ( ) ) ;
566566 } ;
567+ // The request was canonicalized before the matching config was known,
568+ // so `allow_encoded_slash` was taken permissively across every config
569+ // on this host:port. Re-check it against the config that actually
570+ // matched: the opt-in is per-endpoint, and one endpoint enabling it
571+ // must not loosen parsing for the others.
572+ if !config. allow_encoded_slash
573+ && crate :: l7:: path:: canonical_path_has_encoded_slash ( & route_target)
574+ {
575+ crate :: l7:: rest:: RestProvider :: default ( )
576+ . deny_with_redacted_target (
577+ & req,
578+ & ctx. policy_name ,
579+ "request-target contains an encoded '/' (%2F) which is not allowed on this endpoint" ,
580+ client,
581+ None ,
582+ Some ( crate :: l7:: rest:: DenyResponseContext :: from_l7_context ( ctx) ) ,
583+ )
584+ . await ?;
585+ return Ok ( ( ) ) ;
586+ }
567587 if deny_h2c_upgrade_if_requested ( & req, config, ctx, client) . await ? {
568588 return Ok ( ( ) ) ;
569589 }
@@ -6086,6 +6106,186 @@ network_policies:
60866106 . unwrap ( ) ;
60876107 }
60886108
6109+ /// Policy allowing GET on both `/repos/**` and `/admin/**` for the same
6110+ /// host:port, so an encoded-slash denial can only come from the
6111+ /// per-endpoint `allow_encoded_slash` scoping.
6112+ const ENCODED_SLASH_SCOPING_POLICY : & str = r#"
6113+ network_policies:
6114+ route_api:
6115+ name: route_api
6116+ endpoints:
6117+ - host: gateway.example.test
6118+ port: 443
6119+ path: /repos/**
6120+ protocol: rest
6121+ enforcement: enforce
6122+ allow_encoded_slash: true
6123+ rules:
6124+ - allow:
6125+ method: GET
6126+ path: "/repos/**"
6127+ - host: gateway.example.test
6128+ port: 443
6129+ path: /admin/**
6130+ protocol: rest
6131+ enforcement: enforce
6132+ rules:
6133+ - allow:
6134+ method: GET
6135+ path: "/admin/**"
6136+ binaries:
6137+ - { path: /usr/bin/node }
6138+ "# ;
6139+
6140+ fn encoded_slash_scoping_configs ( ) -> Vec < L7EndpointConfig > {
6141+ let rest = |path : & str , allow_encoded_slash : bool | L7EndpointConfig {
6142+ protocol : L7Protocol :: Rest ,
6143+ path : path. into ( ) ,
6144+ tls : crate :: l7:: TlsMode :: Auto ,
6145+ enforcement : EnforcementMode :: Enforce ,
6146+ graphql_max_body_bytes : 0 ,
6147+ json_rpc_max_body_bytes : crate :: l7:: jsonrpc:: DEFAULT_MAX_BODY_BYTES ,
6148+ mcp_strict_tool_names : true ,
6149+ allow_encoded_slash,
6150+ websocket_credential_rewrite : false ,
6151+ request_body_credential_rewrite : false ,
6152+ websocket_graphql_policy : false ,
6153+ credential_signing : crate :: l7:: CredentialSigning :: None ,
6154+ signing_service : String :: new ( ) ,
6155+ signing_region : String :: new ( ) ,
6156+ } ;
6157+ // One endpoint opts in, the other does not.
6158+ vec ! [ rest( "/repos/**" , true ) , rest( "/admin/**" , false ) ]
6159+ }
6160+
6161+ fn encoded_slash_scoping_ctx ( ) -> L7EvalContext {
6162+ L7EvalContext {
6163+ host : "gateway.example.test" . into ( ) ,
6164+ port : 443 ,
6165+ request_default_port : Some ( 443 ) ,
6166+ policy_name : "route_api" . into ( ) ,
6167+ binary_path : "/usr/bin/node" . into ( ) ,
6168+ ancestors : vec ! [ ] ,
6169+ cmdline_paths : vec ! [ ] ,
6170+ secret_resolver : None ,
6171+ ..Default :: default ( )
6172+ }
6173+ }
6174+
6175+ /// Canonicalization runs before the matching config is known, so
6176+ /// `allow_encoded_slash` is taken permissively across the whole
6177+ /// host:port. The endpoint that did *not* opt in must still reject a
6178+ /// `%2F`, otherwise one endpoint's opt-in silently loosens every other
6179+ /// endpoint sharing that host:port.
6180+ #[ tokio:: test]
6181+ async fn route_selected_encoded_slash_optin_does_not_leak_to_other_endpoints ( ) {
6182+ let engine = OpaEngine :: from_strings ( TEST_POLICY , ENCODED_SLASH_SCOPING_POLICY ) . unwrap ( ) ;
6183+ let tunnel_engine = engine
6184+ . clone_engine_for_tunnel ( engine. current_generation ( ) )
6185+ . unwrap ( ) ;
6186+ let configs = encoded_slash_scoping_configs ( ) ;
6187+ let ctx = encoded_slash_scoping_ctx ( ) ;
6188+
6189+ let ( mut app, mut relay_client) = tokio:: io:: duplex ( 8192 ) ;
6190+ let ( mut relay_upstream, mut upstream) = tokio:: io:: duplex ( 8192 ) ;
6191+ let relay = tokio:: spawn ( async move {
6192+ relay_with_route_selection (
6193+ & configs,
6194+ tunnel_engine,
6195+ & mut relay_client,
6196+ & mut relay_upstream,
6197+ & ctx,
6198+ )
6199+ . await
6200+ } ) ;
6201+
6202+ app. write_all (
6203+ b"GET /admin/x%2Fy HTTP/1.1\r \n Host: gateway.example.test\r \n Connection: close\r \n \r \n " ,
6204+ )
6205+ . await
6206+ . unwrap ( ) ;
6207+
6208+ let mut response = [ 0u8 ; 1024 ] ;
6209+ let n = tokio:: time:: timeout ( std:: time:: Duration :: from_secs ( 1 ) , app. read ( & mut response) )
6210+ . await
6211+ . expect ( "denial should reach client" )
6212+ . unwrap ( ) ;
6213+ let response = String :: from_utf8_lossy ( & response[ ..n] ) ;
6214+ assert ! ( response. contains( "403 Forbidden" ) , "{response}" ) ;
6215+ assert ! (
6216+ response. contains( "not allowed on this endpoint" ) ,
6217+ "denial must name the encoded-slash reason: {response}"
6218+ ) ;
6219+
6220+ let mut upstream_bytes = [ 0u8 ; 16 ] ;
6221+ let result = tokio:: time:: timeout (
6222+ std:: time:: Duration :: from_millis ( 100 ) ,
6223+ upstream. read ( & mut upstream_bytes) ,
6224+ )
6225+ . await ;
6226+ assert ! (
6227+ matches!( result, Err ( _) | Ok ( Ok ( 0 ) ) ) ,
6228+ "request must not reach upstream"
6229+ ) ;
6230+
6231+ drop ( app) ;
6232+ tokio:: time:: timeout ( std:: time:: Duration :: from_secs ( 1 ) , relay)
6233+ . await
6234+ . expect ( "relay should finish" )
6235+ . unwrap ( )
6236+ . unwrap ( ) ;
6237+ }
6238+
6239+ /// The converse: tightening the scope must not break the endpoint that
6240+ /// legitimately opted in. A GitLab-style encoded slug still reaches the
6241+ /// upstream verbatim.
6242+ #[ tokio:: test]
6243+ async fn route_selected_encoded_slash_still_allowed_on_opted_in_endpoint ( ) {
6244+ let engine = OpaEngine :: from_strings ( TEST_POLICY , ENCODED_SLASH_SCOPING_POLICY ) . unwrap ( ) ;
6245+ let tunnel_engine = engine
6246+ . clone_engine_for_tunnel ( engine. current_generation ( ) )
6247+ . unwrap ( ) ;
6248+ let configs = encoded_slash_scoping_configs ( ) ;
6249+ let ctx = encoded_slash_scoping_ctx ( ) ;
6250+
6251+ let ( mut app, mut relay_client) = tokio:: io:: duplex ( 8192 ) ;
6252+ let ( mut relay_upstream, mut upstream) = tokio:: io:: duplex ( 8192 ) ;
6253+ let relay = tokio:: spawn ( async move {
6254+ relay_with_route_selection (
6255+ & configs,
6256+ tunnel_engine,
6257+ & mut relay_client,
6258+ & mut relay_upstream,
6259+ & ctx,
6260+ )
6261+ . await
6262+ } ) ;
6263+
6264+ app. write_all (
6265+ b"GET /repos/group%2Fproject HTTP/1.1\r \n Host: gateway.example.test\r \n Connection: close\r \n \r \n " ,
6266+ )
6267+ . await
6268+ . unwrap ( ) ;
6269+
6270+ let mut upstream_bytes = [ 0u8 ; 512 ] ;
6271+ let n = tokio:: time:: timeout (
6272+ std:: time:: Duration :: from_secs ( 1 ) ,
6273+ upstream. read ( & mut upstream_bytes) ,
6274+ )
6275+ . await
6276+ . expect ( "opted-in request should reach upstream" )
6277+ . unwrap ( ) ;
6278+ let forwarded = String :: from_utf8_lossy ( & upstream_bytes[ ..n] ) ;
6279+ assert ! (
6280+ forwarded. contains( "GET /repos/group%2Fproject " ) ,
6281+ "encoded slug must be forwarded verbatim: {forwarded}"
6282+ ) ;
6283+
6284+ drop ( app) ;
6285+ drop ( upstream) ;
6286+ let _ = tokio:: time:: timeout ( std:: time:: Duration :: from_secs ( 1 ) , relay) . await ;
6287+ }
6288+
60896289 #[ tokio:: test]
60906290 async fn route_selected_websocket_upgrade_rejects_invalid_accept_without_forwarding_101 ( ) {
60916291 let data = r#"
0 commit comments